Sfoglia il codice sorgente

refactor(session): keep approval outside projection migration

_Kerman 1 mese fa
parent
commit
c7abeb23bf

+ 2 - 2
docs/subsystems/approval.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/approval.md
-approval.md: ee8ab1cfca1fba6868b5f4931bab3dd9118a7a9b
-approval.zh.md: f84d6a1bcd44e565a5cce85fbeb2a1194a2acade
+approval.md: d9f1169b52e427cd37e7bc54fa37da59d48aecce
+approval.zh.md: abc2361db3d84517c7e7497cfb39b4548160c259

+ 12 - 12
docs/subsystems/approval.md

@@ -30,7 +30,7 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
 
 ## Per-session policy
 
-`ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override.
+`ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. Consumers read it with `ctx.approval.effectivePolicy(session)`; `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override.
 
 ```ts type-equiv
 /**
@@ -57,7 +57,7 @@ Both policies contribute their complete current meaning to the cache-safe runtim
  * Readonly same-process permission question. `callId` links to an already
  * presented tool call, so arguments are not duplicated here.
  */
-interface ApprovalRequest {
+interface ApprovalRequest extends ApprovalRequestEvent {
   /**
    * The agent on whose behalf the question is asked. Routes the question (a
    * UI answerer only answers for agents it owns) and receives the audit
@@ -93,7 +93,7 @@ The audit events are log-only and do not enter the model transcript. Model-visib
 
 ## Cordis API
 
-Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
 
 <a id="ctxapproval--approvalservice"></a>
 
@@ -132,7 +132,7 @@ setPolicy(agent: Agent, policy: ApprovalPolicy): void
 async request(req: ApprovalRequest): Promise<ApprovalOutcome>
 
 /**
- * Read the projected session override without applying the configured default.
+ * Read the session override without applying the configured default.
  * @param session - session whose log supplies the override.
  * @returns the last logged policy, or `undefined` without one.
  */
@@ -141,7 +141,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined
 
 Types: [Agent](core.md) · [Session](session.md)
 
-Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/interaction/user-approval/src/index.ts)
+Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/interaction/user-approval/src/index.ts)
 
 <a id="approval-events"></a>
 
@@ -151,20 +151,20 @@ Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/i
 
 #### `approval/request` — waterfall
 
-Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
+Ask composed answerers for one decision. Return an outcome to claim the request or call `next()` to delegate. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
 
 ```ts cordis-catalog
 /**
  * Ask composed answerers for one decision. Return an outcome to claim the
- * request or call `next()`; failure yields the fail-closed default.
- * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
- * @param req - the pending decision (agent, tool identity, reason, signal).
+ * request or call `next()` to delegate. Scope-filtered dispatch
+ * (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
+ * @param req - pending approval request.
  * @mode waterfall
  */
-'approval/request'(this: Scoped<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
+'approval/request'( this: Scoped<Agent>, req: ApprovalRequestEvent, next: () => Promise<ApprovalOutcome>, ): Promise<ApprovalOutcome>
 ```
 
-Types: [Scoped](scope.md)
+Types: [Agent](core.md) · [Scoped](scope.md)
 
-Source: [`packages/interaction/user-approval/src/index.ts:32`](../../packages/interaction/user-approval/src/index.ts)
+Source: [`packages/interaction/user-approval/src/types.ts`](../../packages/interaction/user-approval/src/types.ts)
 <!-- END GENERATED cordis-surface -->

+ 13 - 13
docs/subsystems/approval.zh.md

@@ -30,7 +30,7 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
 
 ## 按会话策略
 
-`ApprovalPolicy` 决定在交互式应答者运行之前发生什么。`ask` 委托给组合的应答者链,链的无应答默认值为 `unavailable`;`never` 确定性地返回 `rejected`,不分发任何应答者。生效值为会话日志中最后一条 `approval/policy` 事件,回退到服务配置。`setApprovalPolicy(session, policy)` 是唯一的写入路径,因此回放能重建覆盖值。
+`ApprovalPolicy` 决定在交互式应答者运行之前发生什么。`ask` 委托给组合的应答者链,链的无应答默认值为 `unavailable`;`never` 确定性地返回 `rejected`,不分发任何应答者。生效值为会话日志中最后一条 `approval/policy` 事件,回退到服务配置。消费方通过 `ctx.approval.effectivePolicy(session)` 读取;`setApprovalPolicy(session, policy)` 是唯一的写入路径,因此回放能重建覆盖值。
 
 ```ts type-equiv
 /**
@@ -57,7 +57,7 @@ type ApprovalPolicy = 'ask' | 'never'
  * Readonly same-process permission question. `callId` links to an already
  * presented tool call, so arguments are not duplicated here.
  */
-interface ApprovalRequest {
+interface ApprovalRequest extends ApprovalRequestEvent {
   /**
    * The agent on whose behalf the question is asked. Routes the question (a
    * UI answerer only answers for agents it owns) and receives the audit
@@ -93,7 +93,7 @@ interface ApprovalRequest {
 
 ## Cordis API
 
-Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
 
 <a id="ctxapproval--approvalservice"></a>
 
@@ -132,16 +132,16 @@ setPolicy(agent: Agent, policy: ApprovalPolicy): void
 async request(req: ApprovalRequest): Promise<ApprovalOutcome>
 
 /**
- * Read the projected session override without applying the configured default.
+ * Read the session override without applying the configured default.
  * @param session - session whose log supplies the override.
  * @returns the last logged policy, or `undefined` without one.
  */
 overrideOf(session: Session): ApprovalPolicy | undefined
 ```
 
-Types: [Agent](core.md) · [Session](session.md)
+Types: [Agent](core.zh.md) · [Session](session.zh.md)
 
-Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/interaction/user-approval/src/index.ts)
+Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/interaction/user-approval/src/index.ts)
 
 <a id="approval-events"></a>
 
@@ -151,20 +151,20 @@ Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/i
 
 #### `approval/request` — waterfall
 
-Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
+Ask composed answerers for one decision. Return an outcome to claim the request or call `next()` to delegate. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
 
 ```ts cordis-catalog
 /**
  * Ask composed answerers for one decision. Return an outcome to claim the
- * request or call `next()`; failure yields the fail-closed default.
- * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
- * @param req - the pending decision (agent, tool identity, reason, signal).
+ * request or call `next()` to delegate. Scope-filtered dispatch
+ * (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
+ * @param req - pending approval request.
  * @mode waterfall
  */
-'approval/request'(this: Scoped<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
+'approval/request'( this: Scoped<Agent>, req: ApprovalRequestEvent, next: () => Promise<ApprovalOutcome>, ): Promise<ApprovalOutcome>
 ```
 
-Types: [Scoped](scope.md)
+Types: [Agent](core.zh.md) · [Scoped](scope.zh.md)
 
-Source: [`packages/interaction/user-approval/src/index.ts:32`](../../packages/interaction/user-approval/src/index.ts)
+Source: [`packages/interaction/user-approval/src/types.ts`](../../packages/interaction/user-approval/src/types.ts)
 <!-- END GENERATED cordis-surface -->

+ 3 - 7
packages/interaction/user-approval/package.json

@@ -44,12 +44,10 @@
     "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-session-projection": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "dependencies": {
-    "@deepseek-ai/schemastery": "workspace:^",
-    "zod": "^4.4.3"
+    "@deepseek-ai/schemastery": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
@@ -59,8 +57,6 @@
     "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-session-projection": "workspace:^",
-    "@deepseek-ai/dsh-agent-loop": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^"
   }
 }

+ 8 - 76
packages/interaction/user-approval/tests/approval.spec.ts

@@ -6,14 +6,8 @@ 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 SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
-import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
-import ApprovalService, { ApprovalOutcome, ApprovalRequest, ApprovalRequestId, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
-
-function registerTurnBoundary(ctx: Context): void {
-  ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
-}
+import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
 
 /**
  * A minimal Agent stand-in — the service only reaches `agent.session.append`
@@ -21,10 +15,7 @@ function registerTurnBoundary(ctx: Context): void {
  * turn-enclosure precondition); pass `seed` to stage idle/closed logs.
  * Returns the recorded audit appends alongside the fake.
  */
-function fakeAgent(seed: Array<{ type: string; data?: Record<string, unknown>; seq?: number }> = [
-  { type: 'turn/start', seq: 0, data: { turn: 1 } },
-  { type: 'user/message', seq: 1, data: {} },
-]): { agent: Agent; appended: Array<{ type: string; data: Record<string, unknown> }> } {
+function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record<string, unknown> }> } {
   const appended: Array<{ type: string; data: Record<string, unknown> }> = []
   const agent = {
     session: {
@@ -40,8 +31,6 @@ function fakeAgent(seed: Array<{ type: string; data?: Record<string, unknown>; s
 
 async function mounted(): Promise<Context> {
   const ctx = new Context()
-  await ctx.plugin(SessionProjectionRegistry)
-  registerTurnBoundary(ctx)
   await ctx.plugin(ApprovalService)
   return ctx
 }
@@ -61,10 +50,7 @@ describe('ApprovalService.request', () => {
 
   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', seq: 0, data: { turn: 1 } },
-      { type: 'turn/end', seq: 1, data: { turn: 1, reason: { kind: 'completed' } } },
-    ])
+    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)
@@ -130,8 +116,6 @@ describe('ApprovalService.request', () => {
   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(SessionProjectionRegistry)
-    registerTurnBoundary(ctx)
     await ctx.plugin(ApprovalService)
     const session = ctx.sessions.create(SessionId('asked-observer-throw'))
     session.append('turn/start', { turn: 1 })
@@ -155,8 +139,6 @@ describe('ApprovalService.request', () => {
   it('contains an approval/decided observer throw after append and still resolves', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    await ctx.plugin(SessionProjectionRegistry)
-    registerTurnBoundary(ctx)
     await ctx.plugin(ApprovalService)
     const session = ctx.sessions.create(SessionId('decided-observer-throw'))
     session.append('turn/start', { turn: 1 })
@@ -182,7 +164,7 @@ describe('ApprovalService.request', () => {
     const failure = new Error('append failed before log growth')
     const agent = {
       session: {
-        events: [{ type: 'turn/start', seq: 0, data: { turn: 1 } }],
+        events: [{ type: 'turn/start' }],
         append: () => { throw failure },
       },
     } as unknown as Agent
@@ -382,45 +364,15 @@ describe('approval policy (the approval/policy fold)', () => {
     return { agent, session }
   }
 
-  it('folds to the last event, or undefined without one', async () => {
-    const ctx = await mounted()
+  it('folds to the last event, or undefined without one', () => {
     const { session } = sessionAgent('sess-fold')
-    expect(ctx.approval.overrideOf(session)).toBeUndefined()
+    expect(effectiveApprovalPolicy(session.events)).toBeUndefined()
     setApprovalPolicy(session, 'never')
     setApprovalPolicy(session, 'ask')
-    expect(ctx.approval.overrideOf(session)).toBe('ask')
+    expect(effectiveApprovalPolicy(session.events)).toBe('ask')
     expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } })
   })
 
-  it('folds the pending audit pair without double-counting duplicates', async () => {
-    const ctx = await mounted()
-    const { session } = sessionAgent('sess-pending')
-    const asked = session.append('approval/asked', {
-      id: ApprovalRequestId('pending-1'),
-      callId: CallId('call-1'),
-      toolName: 'echo',
-    })
-    session.append('approval/asked', {
-      id: ApprovalRequestId('pending-1'),
-      callId: CallId('call-1'),
-      toolName: 'echo',
-    })
-    const pending = ctx.sessionProjections.snapshot(session).values.approvalPending ?? {}
-    expect(pending).toEqual({
-      'pending-1': { callId: 'call-1', seq: asked.seq },
-    })
-  })
-
-  it('ignores a decided event without a pending asked record', async () => {
-    const ctx = await mounted()
-    const { session } = sessionAgent('sess-unknown-decide')
-    session.append('approval/decided', {
-      id: ApprovalRequestId('never-asked'),
-      outcome: 'rejected',
-    })
-    expect(ctx.sessionProjections.snapshot(session).values.approvalPending).toEqual({})
-  })
-
   it('rejects a policy outside the closed vocabulary before appending', () => {
     const append = vi.fn()
     const session = { append } as unknown as Session
@@ -434,8 +386,6 @@ describe('approval policy (the approval/policy fold)', () => {
     // Direct construction bypasses the plugin schema (the SystemPrompt-test
     // precedent for covering a defaulted Config field's type-narrowing ??).
     const ctx = new Context()
-    new SessionProjectionRegistry(ctx)
-    registerTurnBoundary(ctx)
     const service = new ApprovalService(ctx, {})
     const { agent } = sessionAgent('sess-bare-config')
     ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
@@ -444,8 +394,6 @@ describe('approval policy (the approval/policy fold)', () => {
 
   it('contains an answerer that throws SYNCHRONOUSLY as unavailable', async () => {
     const ctx = new Context()
-    await ctx.plugin(SessionProjectionRegistry)
-    registerTurnBoundary(ctx)
     await ctx.plugin(ApprovalService)
     const { agent } = sessionAgent('sess-syncthrow')
     ctx.on('approval/request', () => { throw new Error('sync bug') })
@@ -454,8 +402,6 @@ describe('approval policy (the approval/policy fold)', () => {
 
   it('a never config rejects deterministically without consulting any answerer', async () => {
     const ctx = new Context()
-    await ctx.plugin(SessionProjectionRegistry)
-    registerTurnBoundary(ctx)
     await ctx.plugin(ApprovalService, { policy: 'never' })
     const consulted = vi.fn()
     ctx.on('approval/request', (_req, next) => { consulted(); return next() })
@@ -470,8 +416,6 @@ describe('approval policy (the approval/policy fold)', () => {
   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(SessionProjectionRegistry)
-    registerTurnBoundary(ctx)
     await ctx.plugin(ApprovalService, { policy: 'never' })
     const { agent } = sessionAgent('sess-gate-2')
     await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
@@ -482,8 +426,6 @@ describe('approval policy (the approval/policy fold)', () => {
     // 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(SessionProjectionRegistry)
-    registerTurnBoundary(ctx)
     await ctx.plugin(ApprovalService, { policy: 'never' })
     const consulted = vi.fn()
     ctx.on('approval/request', () => { consulted(); return Promise.resolve<ApprovalOutcome>('allowed-once') }, { prepend: true })
@@ -495,8 +437,6 @@ describe('approval policy (the approval/policy fold)', () => {
 
   it('a session override outranks the configured default, in both directions', async () => {
     const ctx = new Context()
-    await ctx.plugin(SessionProjectionRegistry)
-    registerTurnBoundary(ctx)
     await ctx.plugin(ApprovalService, { policy: 'never' })
     ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
     const { agent, session } = sessionAgent('sess-gate-3')
@@ -510,8 +450,6 @@ describe('approval policy (the approval/policy fold)', () => {
 
   it('queues a live policy switch for the next model step', async () => {
     const ctx = new Context()
-    await ctx.plugin(SessionProjectionRegistry)
-    registerTurnBoundary(ctx)
     await ctx.plugin(ApprovalService)
     const { agent, session } = sessionAgent('sess-policy-notice')
     const inject = vi.fn<Agent['inject']>()
@@ -520,7 +458,7 @@ describe('approval policy (the approval/policy fold)', () => {
     ctx.approval.setPolicy(liveAgent, 'never')
     ctx.approval.setPolicy(liveAgent, 'never')
 
-    expect(ctx.approval.overrideOf(session)).toBe('never')
+    expect(effectiveApprovalPolicy(session.events)).toBe('never')
     expect(inject).toHaveBeenCalledOnce()
     expect(inject.mock.calls[0]?.[0]).toMatchObject({
       content: [{
@@ -534,8 +472,6 @@ describe('approval policy (the approval/policy fold)', () => {
   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(SessionProjectionRegistry)
-    registerTurnBoundary(ctx)
     await ctx.plugin(ApprovalService)
     const askAgent = sessionAgent('sess-sect-ask').agent
     const { agent: neverAgent, session } = sessionAgent('sess-sect-never')
@@ -551,8 +487,6 @@ describe('approval policy (the approval/policy fold)', () => {
   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(SessionProjectionRegistry)
-    registerTurnBoundary(ctx)
     await ctx.plugin(ApprovalService)
     const { agent, session } = sessionAgent('sess-context-switch')
     const contextFor = async () =>
@@ -569,8 +503,6 @@ describe('approval policy (the approval/policy fold)', () => {
   it('disposes the runtime-context contribution with the service', async () => {
     const ctx = new Context()
     await ctx.plugin(SystemPrompt)
-    await ctx.plugin(SessionProjectionRegistry)
-    registerTurnBoundary(ctx)
     const fiber = await ctx.plugin(ApprovalService)
     const { agent } = sessionAgent('sess-hmr-service-live')
     const contextFor = async () =>

+ 0 - 3
packages/interaction/user-approval/tsconfig.json

@@ -37,9 +37,6 @@
     },
     {
       "path": "../../runtime-diagnostics/invariants"
-    },
-    {
-      "path": "../../session/session-projection"
     }
   ]
 }

+ 1 - 1
packages/subagent/subagent/tests/continuation-inheritance.spec.ts

@@ -82,7 +82,7 @@ function foldedSandboxMode(ctx: Context, id: SessionId, events: readonly Session
 }
 
 function foldedApprovalPolicy(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown {
-  return ctx.sessionProjections.snapshot(Session.create(id, events)).values.approvalPolicy
+  return ctx.approval.overrideOf(Session.create(id, events))
 }
 
 describe('continuable policy inheritance', () => {

+ 0 - 9
pnpm-lock.yaml

@@ -6110,9 +6110,6 @@ importers:
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
-      zod:
-        specifier: ^4.4.3
-        version: 4.4.3
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -6120,9 +6117,6 @@ importers:
       '@deepseek-ai/dsh-agent':
         specifier: workspace:^
         version: link:../../core/agent
-      '@deepseek-ai/dsh-agent-loop':
-        specifier: workspace:^
-        version: link:../../core/agent-loop
       '@deepseek-ai/dsh-brand':
         specifier: workspace:^
         version: link:../../util/brand
@@ -6138,9 +6132,6 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
-      '@deepseek-ai/dsh-session-projection':
-        specifier: workspace:^
-        version: link:../../session/session-projection
       '@deepseek-ai/dsh-system-prompt':
         specifier: workspace:^
         version: link:../../core/system-prompt