diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2d82c69cdf..e78e548ee8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -220,9 +220,18 @@ Source: [`packages/core/agent/src/index.ts:216`](../../packages/core/agent/src/i ## `ctx.approval` — `ApprovalService` -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. +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 runtime-context snapshot and switch notices. ```ts cordis-catalog +/** + * 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 + /** * Ask the composed answerers to decide one readonly same-process request. * The service borrows the request, agent, session, and live signal directly. @@ -251,9 +260,9 @@ async request(req: ApprovalRequest): Promise overrideOf(session: Session): ApprovalPolicy | undefined ``` -Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) +Types: [Agent](../core-data-structures/core.md) · [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) -Source: [`packages/ui/user-approval/src/index.ts:210`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:193`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 44d2dd68cc..63932c13bb 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -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', 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 */', diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index d7f0dcc391..44d62a9c33 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -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) } } diff --git a/packages/ui/permission/tests/projection.spec.ts b/packages/ui/permission/tests/projection.spec.ts index a7e328cae1..3a0e2091c9 100644 --- a/packages/ui/permission/tests/projection.spec.ts +++ b/packages/ui/permission/tests/projection.spec.ts @@ -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 { - const agent = { id: session.id, session } as Agent +async function agentFor(ctx: Context, session: Session) { + const inject = vi.fn() + 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) diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 2abb94e130..6af9de6428 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -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 = 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 => { - 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' }, + })) } /** diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 0738d31b65..73982d3c34 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -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 => { - 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() + 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([]) }) })