Kaynağa Gözat

refactor(agent-loop): own inbox projection in loop

_Kerman 1 ay önce
ebeveyn
işleme
fc746f7851

+ 2 - 2
docs/config-catalog.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/config-catalog.md
-config-catalog.md: 2ed8dd593e69b00d659ac71e96e856456a5e9185
-config-catalog.zh.md: dd22bea8e2f47179d9458cfdba1f74726fb78ae2
+config-catalog.md: 06d2b82a3214b8f48f674925ffda97bf56219882
+config-catalog.zh.md: f1f543e17a28c191d2ae9e58d74b112adb71c256

+ 1 - 1
docs/config-catalog.md

@@ -111,7 +111,7 @@ export interface Config {
 
 Depends on: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md)
 
-Source: [`packages/core/agent-loop/src/index.ts:310`](../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:312`](../packages/core/agent-loop/src/index.ts)
 
 <a id="deepseek-aidsh-agent-presets"></a>
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -113,7 +113,7 @@ export interface Config {
 
 依赖:[`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md)
 
-来源:[`packages/core/agent-loop/src/index.ts:310`](../packages/core/agent-loop/src/index.ts)
+来源:[`packages/core/agent-loop/src/index.ts:312`](../packages/core/agent-loop/src/index.ts)
 
 <a id="deepseek-aidsh-agent-presets"></a>
 

+ 2 - 2
docs/event-producer-consumer.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/event-producer-consumer.md
-event-producer-consumer.md: d56e1e30b53c21b5e9415415863278335c00c209
-event-producer-consumer.zh.md: 9b9d29699b1dd8f11580ef7bb39a326b748e92e9
+event-producer-consumer.md: 086ed4e1d692b69a48da8b68d777a8411a346a94
+event-producer-consumer.zh.md: 4142e7c55e5364b4bd24ec0518edae8f67b7803a

+ 1 - 1
docs/event-producer-consumer.md

@@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 
 | Event | Mode | Declared in | Dispatchers | Listeners |
 | --- | --- | --- | --- | --- |
-| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
+| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:240`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
 | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:92`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
 | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:220`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:229`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 1 - 1
docs/event-producer-consumer.zh.md

@@ -9,7 +9,7 @@
 
 | 事件 | 模式 | 声明位置 | 派发方 | 监听方 |
 | --- | --- | --- | --- | --- |
-| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
+| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:240`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
 | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:92`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
 | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:220`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:229`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 5 - 5
packages/core/agent-loop/src/agent.ts

@@ -33,7 +33,7 @@ import { joinContextSections, renderContextSections, renderPrompt } from '@deeps
 import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
 import type {} from '@deepseek-ai/dsh-session-projection'
 import type { Context } from '@deepseek-ai/cordis'
-import { ProjectedInbox } from './inbox.ts'
+import { ReactLoopInbox } from './inbox.ts'
 import { RuntimeContextProjection } from './runtime-context.ts'
 import { executeToolCalls } from './tool-calls.ts'
 
@@ -69,7 +69,7 @@ function requestProposal(header: EpochHeader): LlmCallConfig {
 
 /** Drives one session through turn and step boundaries. */
 export class ReactLoopAgent implements Agent {
-  readonly inbox: ProjectedInbox
+  readonly inbox: ReactLoopInbox
   private phase: Phase
   private activityDone: Promise<void> = Promise.resolve()
 
@@ -93,12 +93,12 @@ export class ReactLoopAgent implements Agent {
     public readonly session: Session,
   ) {
     this.dispatch = agentEvents(loopCtx, this)
-    this.inbox = new ProjectedInbox(loopCtx.sessionProjections, session, this.dispatch)
+    this.scope = createScope(loopCtx, this)
+    this.ctx = this.scope.ctx.extend({ agent: this })
+    this.inbox = new ReactLoopInbox(this.ctx.sessionProjections, session, this.dispatch)
     /* v8 ignore next -- the loop registers its own turnBoundary unit, so the key is always present */
     const lastTurn = this.loopCtx.sessionProjections.stateOf(session, 'turnBoundary')?.lastTurn ?? 0
     this.phase = { kind: 'idle', lastTurn }
-    this.scope = createScope(loopCtx, this)
-    this.ctx = this.scope.ctx.extend({ agent: this })
     this.runtimeContext = new RuntimeContextProjection(this.ctx, session)
   }
 

+ 63 - 5
packages/core/agent-loop/src/inbox.ts

@@ -1,21 +1,78 @@
 /**
- * Driver-owned command facade over the durable agent inbox projection.
+ * Driver-owned durable agent inbox projection and command facade.
  *
  * @module @deepseek-ai/dsh-agent-loop/inbox
  */
 
 import type { MessageId } from '@deepseek-ai/dsh-llm'
+import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
 import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session'
-import type { AgentEventDispatch, Inbox, InboxState, InboxTarget } from '@deepseek-ai/dsh-agent'
+import type {
+  AgentEventDispatch,
+  Inbox as InboxContract,
+  InboxState,
+  InboxTarget,
+  InboxWireState,
+} from '@deepseek-ai/dsh-agent'
+import { z } from 'zod'
+
+/** Wire validation for pending agent input reconstructed from durable inbox splices. */
+export const inboxProjectionSchema = z.object({
+  'next-turn': z.array(z.custom<UserMessage>()).readonly(),
+  'next-step': z.array(z.custom<UserMessage>()).readonly(),
+}).readonly()
+
+/** Standard fold that reconstructs pending input and rejects invalid durable splice history. */
+export const inboxProjectionDefinition = {
+  key: 'inbox',
+  stateSchema: inboxProjectionSchema,
+  init: (): InboxState => ({ 'next-turn': [], 'next-step': [] }),
+  apply(state: InboxState, event) {
+    if (event.type !== 'agent/inbox/spliced') return state
+    const splice = event.data
+    try {
+      const inbox = state[splice.target]
+      const removedCount = splice.removedCount ?? 0
+      if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length
+        || !Number.isSafeInteger(removedCount) || removedCount < 0
+        || splice.start + removedCount > inbox.length) {
+        throw new Error('invalid inbox splice')
+      }
+      const next = inbox.toSpliced(splice.start, removedCount, ...splice.inserted)
+      const ids = new Set<string>()
+      for (const message of splice.target === 'next-turn'
+        ? [...next, ...state['next-step']]
+        : [...state['next-turn'], ...next]) {
+        if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
+        ids.add(message.id)
+      }
+      return splice.target === 'next-turn'
+        ? { 'next-turn': next, 'next-step': state['next-step'] }
+        : { 'next-turn': state['next-turn'], 'next-step': next }
+    } catch (error: unknown) {
+      throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error })
+    }
+  },
+  wire: {
+    // The wire value is the fold state itself: every pending message already
+    // round-trips the session log as lossless JSON. Only the static type
+    // narrows to the JSON-safe projection table entry.
+    viewSchema: inboxProjectionSchema as unknown as z.ZodType<InboxWireState>,
+    view: (state: InboxState) => state as unknown as InboxWireState,
+  },
+  stateVersion: 1,
+} satisfies ProjectionDefinition<'inbox', InboxState>
 
 /** Concrete inbox implementation constructed only by ReactLoopAgent. */
-export class ProjectedInbox implements Inbox {
+export class ReactLoopInbox implements InboxContract {
   constructor(
     private readonly projections: SessionProjectionRegistry,
     private readonly session: Session,
     private readonly dispatch: AgentEventDispatch,
-  ) {}
+  ) {
+    this.projections.register(inboxProjectionDefinition)
+  }
 
   /** Prompts awaiting individual turns. */
   get nextTurn(): readonly UserMessage[] {
@@ -125,9 +182,10 @@ export class ProjectedInbox implements Inbox {
   /** Read the current durable projection state. */
   private current(): InboxState {
     const state = this.projections.stateOf(this.session, 'inbox')
+    /* v8 ignore next -- the constructor registers this key before any read */
     if (state === undefined) {
       throw new Error(
-        `agent "${this.session.id}" cannot read inbox state: session projection "inbox" is not registered; load AgentRegistry with SessionProjectionRegistry before constructing ReactLoopAgent`,
+        `agent "${this.session.id}" cannot read inbox state: its projection registration is not active`,
       )
     }
     return state

+ 17 - 9
packages/core/agent-loop/src/index.ts

@@ -33,6 +33,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
 import { ReactLoopAgent } from './agent.ts'
 import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
 
+export { inboxProjectionDefinition } from './inbox.ts'
+
 /** Fiber states that cannot own or serve a new lifecycle. */
 const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
   FiberState.UNLOADING,
@@ -581,15 +583,21 @@ export class AgentLoop extends Service implements AgentFactory {
     const untrack = this.ownership.track(dispose)
     let unfollowOwner: () => Promise<void> | void
     try {
-      unfollowOwner = ownerCtx.effect(() => () => {
-        // Owner disposal owns the same quiescence boundary. Its teardown skips
-        // unregistering this already-running owner effect from inside itself.
-        if (disposing !== undefined) return
-        abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
-        return dispose(true)
+      unfollowOwner = ownerCtx.effect(function* () {
+        machine = new ReactLoopAgent(loopCtx, id, options, session)
+        machineReady.resolve()
+        yield machine.scope.rawDispose
+        yield () => {
+          // Owner disposal owns the same quiescence boundary. Its teardown skips
+          // unregistering this already-running owner effect from inside itself.
+          if (disposing !== undefined) return
+          abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
+          return dispose(true)
+        }
       }, `agentLoop.lifecycle(${id})`)
       /* v8 ignore start -- ctx.effect throws only on an inactive fiber, which assertActive() above already rejected */
     } catch (error: unknown) {
+      machineReady.resolve()
       untrack()
       callerSignal?.removeEventListener('abort', onCallerAbort)
       this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
@@ -606,8 +614,9 @@ export class AgentLoop extends Service implements AgentFactory {
       throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason))
     }
     try {
-      const agent = machine = new ReactLoopAgent(loopCtx, id, options, session)
-      machineReady.resolve()
+      /* v8 ignore next -- a synchronous effect exhausts the generator before returning */
+      if (machine === undefined) throw new Error(`agent "${id}" lifecycle did not construct its driver`)
+      const agent = machine
       assertLive()
 
       return {
@@ -631,7 +640,6 @@ export class AgentLoop extends Service implements AgentFactory {
         dispose,
       }
     } catch (error: unknown) {
-      machineReady.resolve()
       void dispose()
       throw error
     }

+ 10 - 6
packages/core/agent-loop/tests/contract-regressions.spec.ts

@@ -8,7 +8,6 @@ import ToolRuntime, { defineContentToolFixture, type PostToolDecision } from '@d
 import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
-import { ReactLoopAgent } from '../src/agent.ts'
 import InvariantRegistry from '@deepseek-ai/dsh-invariants'
 import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
 import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
@@ -250,7 +249,11 @@ describe('abort during tool execution ends the turn', () => {
         ? [event.data.content]
         : []))
       .toEqual([])
-    expect(agent.inbox.nextStep.map(inboxText))
+    expect(agent.session.events
+      .flatMap(event => event.type === 'agent/inbox/spliced' && event.data.target === 'next-step'
+        ? [event.data.inserted.map(inboxText)]
+        : [])
+      .at(-1))
       .toEqual(['accepted result context during disposal'])
     expect(agent.session.events.filter(event => event.type === 'turn/start'))
       .toHaveLength(1)
@@ -535,10 +538,11 @@ describe('turn numbering continues across seeded sessions', () => {
     await ctx2.plugin(AgentLoop, { agents: [] })
     ctx2.llm.registerAdapter(['mock'], second)
 
-    const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
-    const forked = new ReactLoopAgent(
-      ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded,
-    )
+    const { agent: forked } = await ctx2.agents.create({
+      sessionId: SessionId('forked'),
+      seed: [...agent.session.events],
+      agentOptions: { provider: 'mock', model: 'mock' },
+    })
 
     const turns: number[] = []
     ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })

+ 148 - 45
packages/core/agent-loop/tests/inbox.spec.ts

@@ -1,12 +1,11 @@
 import { Context } from '@deepseek-ai/cordis'
-import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
-import type { Agent } from '@deepseek-ai/dsh-agent'
+import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
 import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
 import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
 import type { UserMessage } from '@deepseek-ai/dsh-session'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import { describe, expect, it } from 'vitest'
-import { ProjectedInbox } from '../src/inbox.ts'
+import { ReactLoopInbox } from '../src/inbox.ts'
 
 function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
   const id = SessionId(rawId)
@@ -30,33 +29,140 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
   }
 }
 
-async function inboxAgent(rawId: string): Promise<{ ctx: Context; session: Session; agent: Agent }> {
+async function inboxAgent(rawId: string): Promise<{
+  ctx: Context
+  session: Session
+  agent: Agent
+  inbox: ReactLoopInbox
+}> {
   const ctx = new Context()
   await ctx.plugin(SessionStore)
   await ctx.plugin(SessionProjectionRegistry)
-  await ctx.plugin(AgentRegistry)
   const session = ctx.sessions.create(SessionId(rawId))
   const agent = stubAgent(rawId, { ctx, session })
-  Object.assign(agent, {
-    inbox: new ProjectedInbox(ctx.sessionProjections, session, agentEvents(ctx, agent)),
-  })
-  return { ctx, session, agent }
+  const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
+  Object.assign(agent, { inbox })
+  return { ctx, session, agent, inbox }
+}
+
+async function reconstructPersistedInbox(
+  rawId: string,
+  populate: (session: Session) => void,
+): Promise<Error> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  const session = ctx.sessions.create(SessionId(rawId))
+  populate(session)
+  await ctx.plugin(SessionProjectionRegistry)
+  const agent = stubAgent(rawId, { ctx, session })
+  const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
+  try {
+    void inbox.nextTurn
+  } catch (error: unknown) {
+    if (error instanceof Error) return error
+    throw error
+  }
+  throw new Error('persisted inbox reconstruction unexpectedly succeeded')
 }
 
-describe('ProjectedInbox', () => {
-  it('reports a missing inbox projection as a composition error', async () => {
+describe('ReactLoopInbox', () => {
+  it('registers the durable projection in its constructor', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionProjectionRegistry)
+    const session = ctx.sessions.create(SessionId('inbox-projection'))
+    const pending = createUserMessage({
+      content: [{ type: 'text', text: 'pending' }],
+      source: { kind: 'user' },
+    })
+    session.append('agent/inbox/spliced', {
+      target: 'next-turn', start: 0, inserted: [pending],
+    })
+    const agent = stubAgent('inbox-projection', { ctx, session })
+    const dispatch = agentEvents(ctx, agent)
+    const first = new ReactLoopInbox(ctx.sessionProjections, session, dispatch)
+    const second = new ReactLoopInbox(ctx.sessionProjections, session, dispatch)
+
+    expect(first.nextTurn).toEqual([pending])
+    expect(second.nextTurn).toEqual([pending])
+    expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({
+      'next-turn': [pending],
+      'next-step': [],
+    })
+  })
+
+  it('rejects invalid durable coordinates and duplicate identities during reconstruction', async () => {
+    const outOfRange = await reconstructPersistedInbox('invalid-inbox-range', (session) => {
+      session.append('agent/inbox/spliced', {
+        target: 'next-turn', start: 0, removedCount: 1, inserted: [],
+      })
+    })
+    expect(outOfRange.message).toBe('invalid persisted inbox splice at session seq 0')
+    expect((outOfRange.cause as Error).message).toBe('invalid inbox splice')
+
+    const pending = createUserMessage({
+      content: [{ type: 'text', text: 'duplicate' }],
+      source: { kind: 'user' },
+    })
+    const duplicate = await reconstructPersistedInbox('invalid-inbox-duplicate', (session) => {
+      session.append('agent/inbox/spliced', {
+        target: 'next-turn', start: 0, inserted: [pending],
+      })
+      session.append('agent/inbox/spliced', {
+        target: 'next-step', start: 0, inserted: [pending],
+      })
+    })
+    expect(duplicate.message).toBe('invalid persisted inbox splice at session seq 1')
+    expect((duplicate.cause as Error).message).toBe(`message "${pending.id}" is already pending`)
+  })
+
+  it('projects inherited inbox events in a forked session', async () => {
     const ctx = new Context()
+    await ctx.plugin(SessionStore)
     await ctx.plugin(SessionProjectionRegistry)
-    const agent = stubAgent('missing-inbox-projection', { ctx })
-    const inbox = new ProjectedInbox(
-      ctx.sessionProjections,
-      agent.session,
-      agentEvents(ctx, agent),
-    )
-
-    expect(() => inbox.nextTurn).toThrow(
-      'agent "missing-inbox-projection" cannot read inbox state: session projection "inbox" is not registered; load AgentRegistry with SessionProjectionRegistry before constructing ReactLoopAgent',
-    )
+    const parent = ctx.sessions.create(SessionId('inbox-fork-parent'))
+    const parentAgent = stubAgent('inbox-fork-parent', { ctx, session: parent })
+    const parentInbox = new ReactLoopInbox(ctx.sessionProjections, parent, agentEvents(ctx, parentAgent))
+    const inherited = createUserMessage({
+      content: [{ type: 'text', text: 'parent pending' }],
+      source: { kind: 'user' },
+    })
+    parentInbox.append('next-turn', inherited)
+    const child = ctx.sessions.fork(parent, undefined, SessionId('inbox-fork-child'))
+    const childAgent = stubAgent('inbox-fork-child', { ctx, session: child })
+    const childInbox = new ReactLoopInbox(ctx.sessionProjections, child, agentEvents(ctx, childAgent))
+
+    expect(child.header.seedLength).toBe(parent.events.length)
+    expect(childInbox.nextTurn).toEqual([inherited])
+
+    const own = createUserMessage({
+      content: [{ type: 'text', text: 'child pending' }],
+      source: { kind: 'user' },
+    })
+    childInbox.append('next-turn', own)
+    expect(childInbox.nextTurn).toEqual([inherited, own])
+
+  })
+
+  it('updates the projection cell before session observers run', async () => {
+    const { ctx, session, inbox } = await inboxAgent('inbox-live-projection')
+    const pending = createUserMessage({
+      content: [{ type: 'text', text: 'direct' }],
+      source: { kind: 'user' },
+    })
+    let observed: readonly UserMessage[] | undefined
+    ctx.on('session/event', (subject, event) => {
+      if (subject === session && event.type === 'agent/inbox/spliced') {
+        observed = ctx.sessionProjections.stateOf(session, 'inbox')?.['next-turn']
+      }
+    })
+
+    inbox.append('next-turn', pending)
+
+    expect(observed).toEqual([pending])
+    expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({
+      'next-turn': [pending], 'next-step': [],
+    })
   })
 
   it('replaces a pending message by identity across both lists', async () => {
@@ -65,7 +171,6 @@ describe('ProjectedInbox', () => {
     const discarded: UserMessage[] = []
     ctx.on('agent/inbox/inserted', ({ message }) => void inserted.push(message))
     ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message))
-    const { inbox } = agent
     const original = createUserMessage({
       content: [{ type: 'text', text: 'original' }],
       source: { kind: 'user' },
@@ -82,26 +187,25 @@ describe('ProjectedInbox', () => {
       ...nextStep,
       content: [{ type: 'text', text: 'edited step' }],
     })
-    inbox.append('next-turn', original)
-    inbox.append('next-step', nextStep)
+    agent.inbox.append('next-turn', original)
+    agent.inbox.append('next-step', nextStep)
 
-    expect(inbox.replace(createUserMessage({
+    expect(agent.inbox.replace(createUserMessage({
       content: [{ type: 'text', text: 'missing' }],
       source: { kind: 'user' },
     }).id, replacement)).toBe(false)
-    expect(inbox.replace(original.id, replacement)).toBe(true)
-    expect(inbox.replace(nextStep.id, editedStep)).toBe(true)
-    expect(inbox.nextTurn).toEqual([replacement])
-    expect(inbox.nextStep).toEqual([editedStep])
+    expect(agent.inbox.replace(original.id, replacement)).toBe(true)
+    expect(agent.inbox.replace(nextStep.id, editedStep)).toBe(true)
+    expect(agent.inbox.nextTurn).toEqual([replacement])
+    expect(agent.inbox.nextStep).toEqual([editedStep])
     expect(discarded).toEqual([original, nextStep])
     expect(inserted).toEqual([original, nextStep, replacement, editedStep])
-    expect(() => { inbox.replace(editedStep.id, replacement) })
+    expect(() => { agent.inbox.replace(editedStep.id, replacement) })
       .toThrow(`message "${replacement.id}" is already pending`)
   })
 
   it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', async () => {
     const { agent } = await inboxAgent('splice-inbox')
-    const { inbox } = agent
     const first = createUserMessage({
       content: [{ type: 'text', text: 'first' }],
       source: { kind: 'user' },
@@ -115,30 +219,29 @@ describe('ProjectedInbox', () => {
       source: { kind: 'user' },
     })
 
-    inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second])
-    expect(inbox.nextTurn).toEqual([first, second])
-    expect(inbox.splice('next-turn', -1, 1, [])).toEqual([second])
-    inbox.prepend('next-turn', prefixed)
-    expect(inbox.nextTurn).toEqual([prefixed, first])
-    expect(inbox.remove(second.id)).toBe(false)
-    expect(() => { inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`)
+    agent.inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second])
+    expect(agent.inbox.nextTurn).toEqual([first, second])
+    expect(agent.inbox.splice('next-turn', -1, 1, [])).toEqual([second])
+    agent.inbox.prepend('next-turn', prefixed)
+    expect(agent.inbox.nextTurn).toEqual([prefixed, first])
+    expect(agent.inbox.remove(second.id)).toBe(false)
+    expect(() => { agent.inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`)
   })
 
   it('clears both pending lists as durable cancellations', async () => {
     const { ctx, session, agent } = await inboxAgent('clear-inbox')
     const discarded: UserMessage[] = []
     ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message))
-    const { inbox } = agent
     const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } })
     const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
-    inbox.append('next-turn', nextTurn)
-    inbox.append('next-step', nextStep)
+    agent.inbox.append('next-turn', nextTurn)
+    agent.inbox.append('next-step', nextStep)
     const beforeClear = session.events.length
 
-    inbox.clear()
+    agent.inbox.clear()
 
-    expect(inbox.nextTurn).toEqual([])
-    expect(inbox.nextStep).toEqual([])
+    expect(agent.inbox.nextTurn).toEqual([])
+    expect(agent.inbox.nextStep).toEqual([])
     expect(discarded).toEqual([nextStep, nextTurn])
     expect(session.events.slice(beforeClear).map(event => event.type === 'agent/inbox/spliced'
       ? event.data
@@ -147,7 +250,7 @@ describe('ProjectedInbox', () => {
       { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
     ])
 
-    inbox.clear()
+    agent.inbox.clear()
     expect(session.events).toHaveLength(beforeClear + 2)
   })
 })

+ 2 - 2
packages/core/agent/README.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 packages/core/agent/README.md
-README.md: fae67d0c63b902a19fe27d6ab652b04e486f8eb6
-README.zh.md: fcb09fa68d79aa5e08cadd1a036dcdfbf0721ced
+README.md: ff66f47c0c748bbb12d50e16adf28865c457a1a4
+README.zh.md: 323eb771f36b38ca914647cd918144f33327ee0d

+ 1 - 2
packages/core/agent/README.md

@@ -86,7 +86,7 @@ The package is built on one separation: the public `Agent` surface and registry
 
 ### Durable inbox
 
-`AgentRegistry` contributes the standard `inbox` session projection whenever the projection registry is composed. The registry folds durable `agent/inbox/spliced` events once and remains the sole owner of the live `{ 'next-turn', 'next-step' }` state. `Agent.inbox` exposes only the structural `Inbox` interface; dsh-agent-loop owns the package-internal `ProjectedInbox` that reads the projection. Missing projection composition fails explicitly, while reconstruction rejects unsafe or out-of-range splice coordinates and duplicate `MessageId` values across both pending lists and reports the offending event seq.
+`Agent.inbox` exposes only the structural `Inbox` interface and the projection vocabulary stays in this package. dsh-agent-loop owns the package-internal `ReactLoopInbox` and the standard `inbox` projection; constructing its concrete inbox ensures that the projection registry owns one registration for the durable `agent/inbox/spliced` fold. The registry remains the sole owner of the live `{ 'next-turn', 'next-step' }` state. Reconstruction rejects unsafe or out-of-range splice coordinates and duplicate `MessageId` values across both pending lists and reports the offending event seq.
 
 `Inbox` exposes pending `nextTurn` and `nextStep` messages and mutates them through `append`, `prepend`, `replace`, `remove`, `clear`, and `splice`. Ordinary removals and `clear()` are durable cancellations. At a step boundary, the loop's internal implementation claims pending input through pure deletion splices. Live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`.
 
@@ -97,7 +97,6 @@ The package is built on one separation: the public `Agent` surface and registry
 | [`src/index.ts`](src/index.ts) | Plugin entry: `AgentRegistry`, factory slot, initiator scope, `CreateAgentOptions`/`ResumeAgentOptions` |
 | [`src/runtime-types.ts`](src/runtime-types.ts) | `Agent`, structural `Inbox`, `AgentStatus`, and the `agent/*` event declarations |
 | [`src/types.ts`](src/types.ts) | `AgentOptions`, cancellation causes, and inbox projection vocabulary |
-| [`src/inbox-projection.ts`](src/inbox-projection.ts) | Standard projection over durable `agent/inbox/spliced` events |
 | [`src/dispatch.ts`](src/dispatch.ts) | `agentEvents` fused dispatcher and `assembleContextFor(agent)` |
 | [`src/consumed-work.ts`](src/consumed-work.ts) | `foldConsumedWork(events)`: what the log's consumed work became |
 | [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`: coupling one selection to assembly and routing |

+ 1 - 2
packages/core/agent/README.zh.md

@@ -86,7 +86,7 @@ await handle.agent.whenIdle()
 
 ### 持久 inbox
 
-`AgentRegistry` 会在投影注册表已组合时贡献标准 `inbox` 会话投影。注册表只折叠一次持久 `agent/inbox/spliced` 事件,并继续作为实时 `{ 'next-turn', 'next-step' }` 状态的唯一所有者。`Agent.inbox` 只暴露结构化 `Inbox` 接口;dsh-agent-loop 持有读取该投影的包内部 `ProjectedInbox`。投影组合缺失时会明确失败;重建过程则会拒绝不安全或越界的 splice 坐标,以及跨两份待处理列表重复的 `MessageId`,并报告出错事件的 seq。
+`Agent.inbox` 只暴露结构化 `Inbox` 接口,投影词汇仍位于本包。dsh-agent-loop 持有包内部的 `ReactLoopInbox` 与标准 `inbox` 投影;构造具体 inbox 时会确保投影注册表为持久 `agent/inbox/spliced` fold 持有一份注册。注册表继续作为实时 `{ 'next-turn', 'next-step' }` 状态的唯一所有者。重建过程会拒绝不安全或越界的 splice 坐标,以及跨两份待处理列表重复的 `MessageId`,并报告出错事件的 seq。
 
 `Inbox` 暴露待处理的 `nextTurn` 与 `nextStep` 消息,并通过 `append`、`prepend`、`replace`、`remove`、`clear` 与 `splice` 变更它们。普通删除和 `clear()` 都是持久取消。在步骤边界,循环的内部实现会通过纯删除 splice 领取待处理输入。实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。
 
@@ -97,7 +97,6 @@ await handle.agent.whenIdle()
 | [`src/index.ts`](src/index.ts) | 插件入口:`AgentRegistry`、工厂槽位、发起方作用域、`CreateAgentOptions`/`ResumeAgentOptions` |
 | [`src/runtime-types.ts`](src/runtime-types.ts) | `Agent`、结构化 `Inbox`、`AgentStatus` 与 `agent/*` 事件声明 |
 | [`src/types.ts`](src/types.ts) | `AgentOptions`、取消原因与收件箱投影词汇 |
-| [`src/inbox-projection.ts`](src/inbox-projection.ts) | 持久 `agent/inbox/spliced` 事件之上的标准投影 |
 | [`src/dispatch.ts`](src/dispatch.ts) | `agentEvents` 融合分发器与 `assembleContextFor(agent)` |
 | [`src/consumed-work.ts`](src/consumed-work.ts) | `foldConsumedWork(events)`:日志消费掉的工作最终怎样了 |
 | [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`:把一个选择耦合到组装与路由 |

+ 2 - 7
packages/core/agent/package.json

@@ -44,11 +44,7 @@
     "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-session-projection": "workspace:^"
-  },
-  "dependencies": {
-    "zod": "^4.4.3"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-invariants": "workspace:^",
@@ -59,7 +55,6 @@
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-typert-registry": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-session-projection": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^"
   }
 }

+ 0 - 53
packages/core/agent/src/inbox-projection.ts

@@ -1,53 +0,0 @@
-/** Inbox projection schema and its inferred wire value. */
-
-import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
-import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
-import { z } from 'zod'
-import type { InboxState, InboxWireState } from './types.ts'
-
-/** Wire validation for pending agent input reconstructed from durable inbox splices. */
-export const inboxProjectionSchema = z.object({
-  'next-turn': z.array(z.custom<UserMessage>()).readonly(),
-  'next-step': z.array(z.custom<UserMessage>()).readonly(),
-}).readonly()
-
-/** Standard fold that reconstructs pending input and rejects invalid durable splice history. */
-export const inboxProjectionDefinition = {
-  key: 'inbox',
-  stateSchema: inboxProjectionSchema,
-  init: (): InboxState => ({ 'next-turn': [], 'next-step': [] }),
-  apply(state: InboxState, event) {
-    if (event.type !== 'agent/inbox/spliced') return state
-    const splice = event.data
-    try {
-      const inbox = state[splice.target]
-      const removedCount = splice.removedCount ?? 0
-      if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length
-        || !Number.isSafeInteger(removedCount) || removedCount < 0
-        || splice.start + removedCount > inbox.length) {
-        throw new Error('invalid inbox splice')
-      }
-      const next = inbox.toSpliced(splice.start, removedCount, ...splice.inserted)
-      const ids = new Set<string>()
-      for (const message of splice.target === 'next-turn'
-        ? [...next, ...state['next-step']]
-        : [...state['next-turn'], ...next]) {
-        if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
-        ids.add(message.id)
-      }
-      return splice.target === 'next-turn'
-        ? { 'next-turn': next, 'next-step': state['next-step'] }
-        : { 'next-turn': state['next-turn'], 'next-step': next }
-    } catch (error: unknown) {
-      throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error })
-    }
-  },
-  wire: {
-    // The wire value is the fold state itself: every pending message already
-    // round-trips the session log as lossless JSON. Only the static type
-    // narrows to the JSON-safe projection table entry.
-    viewSchema: inboxProjectionSchema as unknown as z.ZodType<InboxWireState>,
-    view: (state: InboxState) => state as unknown as InboxWireState,
-  },
-  stateVersion: 1,
-} satisfies ProjectionDefinition<'inbox', InboxState>

+ 0 - 6
packages/core/agent/src/index.ts

@@ -12,9 +12,6 @@ import { isPromise } from 'node:util/types'
 import { scopeTarget } from '@deepseek-ai/dsh-scope'
 import type { Scoped } from '@deepseek-ai/dsh-scope'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
-// Type-only: resolves ctx.sessionProjections for the optional Inbox projection contribution.
-import type {} from '@deepseek-ai/dsh-session-projection'
-import { inboxProjectionDefinition } from './inbox-projection.ts'
 import type { Agent } from './types.ts'
 import type { AgentOptions } from './runtime-types.ts'
 
@@ -258,9 +255,6 @@ export class AgentRegistry extends Service {
 
   constructor(ctx: Context) {
     super(ctx, 'agents')
-    ctx.inject(['sessionProjections'], (projectionCtx) => {
-      projectionCtx.sessionProjections.register(inboxProjectionDefinition)
-    })
     ctx.inject(['typert'], (typeCtx) => {
       typeCtx.typert.lookups.register('agent', {
         parameter: 'agent',

+ 1 - 131
packages/core/agent/tests/agent.spec.ts

@@ -1,9 +1,7 @@
 import { describe, expect, expectTypeOf, it } from 'vitest'
 import { Context, Service, symbols } from '@deepseek-ai/cordis'
-import { createUserMessage } from '@deepseek-ai/dsh-llm'
-import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
 import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
-import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
 
 import type {
@@ -40,134 +38,6 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
   return agent
 }
 
-async function reconstructPersistedInbox(
-  rawId: string,
-  populate: (session: Session) => void,
-): Promise<Error> {
-  const ctx = new Context()
-  await ctx.plugin(SessionStore)
-  const session = ctx.sessions.create(SessionId(rawId))
-  populate(session)
-  await ctx.plugin(SessionProjectionRegistry)
-  await ctx.plugin(AgentRegistry)
-  try {
-    ctx.sessionProjections.stateOf(session, 'inbox')
-  } catch (error: unknown) {
-    if (error instanceof Error) return error
-    throw error
-  }
-  throw new Error('persisted inbox reconstruction unexpectedly succeeded')
-}
-
-describe('Inbox projection', () => {
-  it('rejects invalid durable coordinates and duplicate identities during reconstruction', async () => {
-    const outOfRange = await reconstructPersistedInbox('invalid-inbox-range', (session) => {
-      session.append('agent/inbox/spliced', {
-        target: 'next-turn', start: 0, removedCount: 1, inserted: [],
-      })
-    })
-    expect(outOfRange.message).toBe('invalid persisted inbox splice at session seq 0')
-    expect((outOfRange.cause as Error).message).toBe('invalid inbox splice')
-
-    const pending = createUserMessage({
-      content: [{ type: 'text', text: 'duplicate' }],
-      source: { kind: 'user' },
-    })
-    const duplicate = await reconstructPersistedInbox('invalid-inbox-duplicate', (session) => {
-      session.append('agent/inbox/spliced', {
-        target: 'next-turn', start: 0, inserted: [pending],
-      })
-      session.append('agent/inbox/spliced', {
-        target: 'next-step', start: 0, inserted: [pending],
-      })
-    })
-    expect(duplicate.message).toBe('invalid persisted inbox splice at session seq 1')
-    expect((duplicate.cause as Error).message).toBe(`message "${pending.id}" is already pending`)
-  })
-
-  it('projects inherited inbox events in a forked session', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SessionStore)
-    await ctx.plugin(SessionProjectionRegistry)
-    await ctx.plugin(AgentRegistry)
-    const parent = ctx.sessions.create(SessionId('inbox-fork-parent'))
-    const inherited = createUserMessage({
-      content: [{ type: 'text', text: 'parent pending' }],
-      source: { kind: 'user' },
-    })
-    parent.append('agent/inbox/spliced', {
-      target: 'next-turn', start: 0, inserted: [inherited],
-    })
-    const child = ctx.sessions.fork(parent, undefined, SessionId('inbox-fork-child'))
-
-    expect(child.header.seedLength).toBe(parent.events.length)
-    expect(ctx.sessionProjections.stateOf(child, 'inbox')).toEqual({
-      'next-turn': [inherited],
-      'next-step': [],
-    })
-
-    const own = createUserMessage({
-      content: [{ type: 'text', text: 'child pending' }],
-      source: { kind: 'user' },
-    })
-    child.append('agent/inbox/spliced', {
-      target: 'next-turn', start: 1, inserted: [own],
-    })
-    expect(ctx.sessionProjections.stateOf(child, 'inbox')?.['next-turn']).toEqual([inherited, own])
-  })
-
-  it('registers the durable inbox projection from the Agent registry', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SessionStore)
-    await ctx.plugin(SessionProjectionRegistry)
-    const agentFiber = ctx.plugin(AgentRegistry)
-    await agentFiber
-    const session = ctx.sessions.create(SessionId('inbox-projection'))
-    const pending = createUserMessage({
-      content: [{ type: 'text', text: 'pending' }],
-      source: { kind: 'user' },
-    })
-
-    session.append('agent/inbox/spliced', {
-      target: 'next-turn', start: 0, inserted: [pending],
-    })
-
-    expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({
-      'next-turn': [pending],
-      'next-step': [],
-    })
-    await agentFiber.dispose()
-    expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
-  })
-
-  it('updates the projection cell before session observers run', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SessionStore)
-    await ctx.plugin(SessionProjectionRegistry)
-    await ctx.plugin(AgentRegistry)
-    const session = ctx.sessions.create(SessionId('inbox-live-projection'))
-    const pending = createUserMessage({
-      content: [{ type: 'text', text: 'direct' }],
-      source: { kind: 'user' },
-    })
-    let observed: readonly UserMessage[] | undefined
-    ctx.on('session/event', (subject, event) => {
-      if (subject === session && event.type === 'agent/inbox/spliced') {
-        observed = ctx.sessionProjections.stateOf(session, 'inbox')?.['next-turn']
-      }
-    })
-
-    session.append('agent/inbox/spliced', {
-      target: 'next-turn', start: 0, inserted: [pending],
-    })
-
-    expect(observed).toEqual([pending])
-    expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({
-      'next-turn': [pending], 'next-step': [],
-    })
-  })
-})
-
 describe('AgentRegistry', () => {
   it('contributes Agent lookup and scoped Context providers while Typert is live', async () => {
     const ctx = new Context()

+ 0 - 3
packages/core/agent/tsconfig.json

@@ -32,9 +32,6 @@
     {
       "path": "../../runtime-diagnostics/invariants"
     },
-    {
-      "path": "../../session/session-projection"
-    },
     {
       "path": "../../typert/protocol"
     }

+ 1 - 0
packages/test-support/agent-loop-testkit/package.json

@@ -33,6 +33,7 @@
   "license": "MIT",
   "peerDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-agent-loop": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",

+ 5 - 1
packages/test-support/agent-loop-testkit/src/inbox.ts

@@ -1,4 +1,5 @@
 import type { Inbox, InboxState, InboxTarget } from '@deepseek-ai/dsh-agent'
+import { inboxProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
 import type { MessageId } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session'
 import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
@@ -13,7 +14,7 @@ export interface InboxFixture {
 
 /**
  * Create a session-backed structural Inbox test double for consumer tests.
- * @param projections - registry holding the standard inbox projection.
+ * @param projections - registry that will own the fixture's standard inbox projection registration.
  * @param session - session whose durable splices back the test double.
  * @returns the structural Inbox and a separate loop-driver claim operation.
  */
@@ -21,8 +22,11 @@ export function createInboxFixture(
   projections: SessionProjectionRegistry,
   session: Session,
 ): InboxFixture {
+  projections.register(inboxProjectionDefinition)
+
   const current = (): InboxState => {
     const state = projections.stateOf(session, 'inbox')
+    /* v8 ignore next -- createInboxFixture holds the registration for the context lifetime */
     if (state === undefined) throw new Error('test inbox requires the standard inbox projection')
     return state
   }

+ 0 - 2
packages/test-support/agent-loop-testkit/src/index.ts

@@ -9,7 +9,6 @@ import type { Context } from '@deepseek-ai/cordis'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import LlmRuntime from '@deepseek-ai/dsh-llm'
 import SessionStore from '@deepseek-ai/dsh-session'
-import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
 import ToolRuntime from '@deepseek-ai/dsh-tools'
@@ -44,7 +43,6 @@ export async function mountAgentLoopTestDependencies(
 ): Promise<void> {
   await ctx.plugin(LlmRuntime)
   await ctx.plugin(SessionStore)
-  await ctx.plugin(SessionProjectionRegistry)
   await ctx.plugin(SystemPrompt, options.systemPrompt ?? {})
   await ctx.plugin(ToolRuntime, options.tools ?? {})
   await ctx.plugin(AgentRegistry)

+ 10 - 3
packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts

@@ -18,6 +18,7 @@ describe('dsh-agent-loop-testkit', () => {
       systemPrompt: { persona: 'Test persona.' },
       tools: { mode: 'native' },
     })
+    await ctx.plugin(SessionProjectionRegistry)
 
     expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain('Test persona.')
     await expect(ctx.plugin(AgentLoop, { agents: [] })).resolves.toBeDefined()
@@ -28,6 +29,7 @@ describe('dsh-agent-loop-testkit', () => {
   it('provides a session-backed structural Inbox with separate driver claims', async () => {
     const ctx = new Context()
     await mountAgentLoopTestDependencies(ctx)
+    await ctx.plugin(SessionProjectionRegistry)
     const session = ctx.sessions.create(SessionId('agent-loop-testkit-inbox'))
     const fixture = createInboxFixture(ctx.sessionProjections, session)
     const firstTurn = message('first turn')
@@ -69,15 +71,20 @@ describe('dsh-agent-loop-testkit', () => {
     await ctx.fiber.dispose()
   })
 
-  it('reports a missing standard inbox projection', async () => {
+  it('registers the standard inbox projection for a standalone fixture', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionProjectionRegistry)
+    const session = Session.create(SessionId('agent-loop-testkit-standalone-inbox'))
     const fixture = createInboxFixture(
       ctx.sessionProjections,
-      Session.create(SessionId('agent-loop-testkit-missing-inbox')),
+      session,
     )
 
-    expect(() => fixture.inbox.nextStep).toThrow('test inbox requires the standard inbox projection')
+    expect(fixture.inbox.nextStep).toEqual([])
+    expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({
+      'next-turn': [],
+      'next-step': [],
+    })
 
     await ctx.fiber.dispose()
   })

+ 6 - 0
packages/test-support/agent-loop-testkit/tsconfig.json

@@ -17,6 +17,9 @@
     {
       "path": "../../core/agent"
     },
+    {
+      "path": "../../core/agent-loop"
+    },
     {
       "path": "../../llm/llm"
     },
@@ -31,6 +34,9 @@
     },
     {
       "path": "../../runtime-diagnostics/invariants"
+    },
+    {
+      "path": "../../session/session-projection"
     }
   ]
 }

+ 0 - 4
pnpm-lock.yaml

@@ -4233,10 +4233,6 @@ importers:
         version: link:../../core/system-prompt
 
   packages/core/agent:
-    dependencies:
-      zod:
-        specifier: ^4.4.3
-        version: 4.4.3
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^