fix(approval): preserve model policy switch notices
This commit is contained in:
@@ -146,6 +146,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
key: 'approval',
|
||||
summary: 'Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'setPolicy(agent: Agent, policy: ApprovalPolicy): void',
|
||||
jsDoc: '/**\n * Switch one live agent\'s policy and queue the transition for its next model\n * step. Session initialization uses {@link setApprovalPolicy} directly\n * because there is no previously visible policy to change.\n * @param agent - the live agent whose policy is changing.\n * @param policy - the new effective policy.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async request(req: ApprovalRequest): Promise<ApprovalOutcome>',
|
||||
jsDoc: '/**\n * Ask the composed answerers to decide one readonly same-process request.\n * The service borrows the request, agent, session, and live signal directly.\n * The request requires an open turn because the audit pair must be enclosed\n * by the durable log\'s commit/replay boundary; an idle ask rejects before\n * appending anything. The answerer phase always produces an outcome: an\n * aborted signal yields `\'cancelled\'`, a missing or throwing answerer yields\n * `\'unavailable\'` (fail closed), and a rogue non-vocabulary return value is\n * normalized to `\'unavailable\'`. A failure that prevents either audit append\n * from committing still rejects because returning an unlogged decision would\n * violate the pair. Session contains post-commit observer failures, so an\n * authoritative append cannot reject the request or suppress its matching\n * audit event.\n * @param req - the pending decision (agent, tool identity, reason, signal).\n * @returns the closed outcome; `\'allowed-once\'` is the only grant.\n * @throws when no turn is open or either audit event fails before the session\n * append commit point.\n */',
|
||||
|
||||
@@ -270,7 +270,7 @@ export class PermissionService extends Service {
|
||||
if (!this.names.includes(name)) {
|
||||
return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` }
|
||||
}
|
||||
this.set(agent.session, name)
|
||||
this.apply(agent.session, name, (policy) =>{ this.ctx.approval.setPolicy(agent, policy) })
|
||||
return { kind: 'success', text: `preset ${name}` }
|
||||
},
|
||||
})
|
||||
@@ -373,6 +373,11 @@ export class PermissionService extends Service {
|
||||
* @param name - the preset to switch to; unknown names throw.
|
||||
*/
|
||||
set(session: Session, name: string): void {
|
||||
this.apply(session, name, (policy) =>{ setApprovalPolicy(session, policy) })
|
||||
}
|
||||
|
||||
/** Apply one preset with the caller-selected live or initialization policy writer. */
|
||||
private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void {
|
||||
const spec = this.resolve(name)
|
||||
if (this.current(session.events) !== name) {
|
||||
session.append('permission/preset', { preset: name })
|
||||
@@ -382,7 +387,7 @@ export class PermissionService extends Service {
|
||||
setSandboxMode(session, spec.sandbox)
|
||||
}
|
||||
if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) {
|
||||
setApprovalPolicy(session, spec.approval)
|
||||
setApproval(spec.approval)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* service removes the key (HMR safety).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
@@ -19,6 +19,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import PermissionService from '@deepseek-ai/dsh-permission'
|
||||
import type { Config } from '@deepseek-ai/dsh-permission'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
|
||||
async function harness(options: { withPermission?: boolean; config?: Config } = {}): Promise<{ ctx: Context; session: Session }> {
|
||||
const ctx = new Context()
|
||||
@@ -31,16 +32,17 @@ async function harness(options: { withPermission?: boolean; config?: Config } =
|
||||
run() { throw new Error('permission tests do not execute bash') },
|
||||
start() { throw new Error('permission tests do not execute bash') },
|
||||
})
|
||||
ctx.provide('approval', { config: { policy: 'ask' } })
|
||||
await ctx.plugin(ApprovalService)
|
||||
if (options.withPermission !== false) await ctx.plugin(PermissionService, options.config ?? {})
|
||||
return { ctx, session: ctx.sessions.create(SessionId('perm-projected')) }
|
||||
}
|
||||
|
||||
/** Mint a scoped agent over a live session (the command executor's addressing shape). */
|
||||
async function agentFor(ctx: Context, session: Session): Promise<Agent> {
|
||||
const agent = { id: session.id, session } as Agent
|
||||
async function agentFor(ctx: Context, session: Session) {
|
||||
const inject = vi.fn<Agent['inject']>()
|
||||
const agent = { id: session.id, session, inject } as unknown as Agent
|
||||
await ctx.plugin(Object.assign((inner: Context) => { createScope(inner, agent) }, { inject: ['commands'] }))
|
||||
return agent
|
||||
return { agent, inject }
|
||||
}
|
||||
|
||||
describe('permissions projection unit', () => {
|
||||
@@ -87,17 +89,23 @@ describe('permissions projection unit', () => {
|
||||
describe('/permission command', () => {
|
||||
it('switches through permission.set and logs the lifecycle pair', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const agent = await agentFor(ctx, session)
|
||||
const { agent, inject } = await agentFor(ctx, session)
|
||||
const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal)
|
||||
expect(execution?.result).toEqual({ kind: 'success', text: 'preset danger-full-access' })
|
||||
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
|
||||
expect(inject.mock.calls[0]?.[0]).toMatchObject({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'The approval policy changed from "ask" to "never" (changed by the user).',
|
||||
}],
|
||||
})
|
||||
const run = session.events.find(event => event.type === 'command/run')
|
||||
expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' })
|
||||
})
|
||||
|
||||
it('reports the current preset and the table on bare invocation', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const agent = await agentFor(ctx, session)
|
||||
const { agent } = await agentFor(ctx, session)
|
||||
const execution = await ctx.commands.execute(agent, '/permission', new AbortController().signal)
|
||||
expect(execution?.result).toEqual({
|
||||
kind: 'success',
|
||||
@@ -108,7 +116,7 @@ describe('/permission command', () => {
|
||||
|
||||
it('rejects an unknown preset without touching the log', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const agent = await agentFor(ctx, session)
|
||||
const { agent } = await agentFor(ctx, session)
|
||||
const before = session.events.filter(event =>
|
||||
event.type !== 'command/run' && event.type !== 'command/done')
|
||||
const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
@@ -59,8 +59,8 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
/**
|
||||
* The session's approval policy was switched — log-only, durable,
|
||||
* replayable, never in the model transcript (the model learns the policy
|
||||
* from the cache-safe runtime-context snapshot). The LAST such
|
||||
* event is the session's override ({@link effectiveApprovalPolicy}).
|
||||
* from the runtime-context snapshot and live switch notices). The LAST
|
||||
* such event is the session's override ({@link effectiveApprovalPolicy}).
|
||||
* `source: 'delegation'` marks an override seeded into a child; an absent
|
||||
* source is a runtime switch.
|
||||
*/
|
||||
@@ -102,22 +102,6 @@ const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions t
|
||||
/** Model-facing statement for an interactive policy that may still fail closed. */
|
||||
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
|
||||
|
||||
/** Read the latest visible policy from the runtime-context projection owned by system-prompt. */
|
||||
function toldApprovalPolicy(session: Session): ApprovalPolicy | undefined {
|
||||
const messages = session.deriveMessages()
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index]
|
||||
if (message?.source.kind !== 'plugin' || message.source.plugin !== '@deepseek-ai/dsh-system-prompt') continue
|
||||
for (const block of message.content) {
|
||||
if (block.type !== 'text') continue
|
||||
if (block.text.includes(NEVER_SENTENCE)) return 'never'
|
||||
if (block.text.includes(ASK_SENTENCE)) return 'ask'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's approval-policy override: the last `approval/policy` event in
|
||||
* the log, or undefined when the session never switched (callers apply the
|
||||
@@ -204,8 +188,7 @@ export interface Config {
|
||||
/**
|
||||
* Approval service that applies session policy before answerers and logs every
|
||||
* ask/outcome pair to the requesting session. It exposes deterministic policy
|
||||
* changes to the model through the cache-safe runtime-context snapshot and
|
||||
* prompt-submission notices.
|
||||
* changes to the model through the runtime-context snapshot and switch notices.
|
||||
*/
|
||||
export class ApprovalService extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -232,57 +215,26 @@ export class ApprovalService extends Service {
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Visibility layer 2: pre-step processing narrates a policy delta in the
|
||||
// exact request whose prompt is being finalized. The last request header
|
||||
// is authoritative for what the model was told, so a later listener that
|
||||
// rejects or throws cannot advance narration state. 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.
|
||||
ctx.on('agent/pre-step', async (
|
||||
agent,
|
||||
_messages,
|
||||
_signal,
|
||||
next,
|
||||
): Promise<PreStepDecision> => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'reject') return decision
|
||||
const session = agent.session
|
||||
const events = session.events
|
||||
let overrideIndex = -1
|
||||
let overrideSource: 'delegation' | undefined
|
||||
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
|
||||
overrideSource = event.data.source
|
||||
} else if (headerIndex < 0 && event.type === 'request/header') {
|
||||
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(session)
|
||||
const told = toldApprovalPolicy(session)
|
||||
// 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 decision
|
||||
const cause = overrideSource === 'delegation'
|
||||
? 'inherited from the delegating session'
|
||||
: overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
|
||||
return {
|
||||
...decision,
|
||||
messages: [
|
||||
...decision.messages,
|
||||
createUserMessage({
|
||||
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
|
||||
source: { kind: 'plugin', plugin: 'user-approval' },
|
||||
}),
|
||||
],
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Switch one live agent's policy and queue the transition for its next model
|
||||
* step. Session initialization uses {@link setApprovalPolicy} directly
|
||||
* because there is no previously visible policy to change.
|
||||
* @param agent - the live agent whose policy is changing.
|
||||
* @param policy - the new effective policy.
|
||||
*/
|
||||
setPolicy(agent: Agent, policy: ApprovalPolicy): void {
|
||||
const previous = this.effectivePolicy(agent.session)
|
||||
if (previous === policy) return
|
||||
setApprovalPolicy(agent.session, policy)
|
||||
agent.inject(createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `The approval policy changed from "${previous}" to "${policy}" (changed by the user).`,
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'user-approval' },
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 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'
|
||||
@@ -354,62 +354,16 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
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.
|
||||
* An agent stand-in over a REAL Session — gate and context 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
|
||||
const agent = { id, session } as unknown as Agent
|
||||
return { agent, session }
|
||||
}
|
||||
|
||||
const submitPrompt = async (ctx: Context, agent: Agent): Promise<void> => {
|
||||
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()
|
||||
@@ -494,6 +448,27 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('queues a live policy switch for the next model step', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session } = sessionAgent('sess-policy-notice')
|
||||
const inject = vi.fn<Agent['inject']>()
|
||||
const liveAgent = { ...agent, inject } as Agent
|
||||
|
||||
ctx.approval.setPolicy(liveAgent, 'never')
|
||||
ctx.approval.setPolicy(liveAgent, 'never')
|
||||
|
||||
expect(effectiveApprovalPolicy(session.events)).toBe('never')
|
||||
expect(inject).toHaveBeenCalledOnce()
|
||||
expect(inject.mock.calls[0]?.[0]).toMatchObject({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'The approval policy changed from "ask" to "never" (changed by the user).',
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'user-approval' },
|
||||
})
|
||||
})
|
||||
|
||||
it('contributes the complete current ask or never policy as cache-safe context', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -509,26 +484,6 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
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)
|
||||
@@ -545,169 +500,15 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
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 () => {
|
||||
it('disposes the runtime-context contribution with the service', 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 { agent } = sessionAgent('sess-hmr-service-live')
|
||||
const contextFor = async () =>
|
||||
(await ctx.systemPrompt.assemble({ agent: live.agent })).contexts.find(context => context.name === 'approval:policy')
|
||||
(await ctx.systemPrompt.assemble({ 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([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user