Преглед на файлове

test(agent-loop): keep inbox internals private

_Kerman преди 1 месец
родител
ревизия
a96ec3dbd6
променени са 27 файла, в които са добавени 319 реда и са изтрити 119 реда
  1. 2 2
      docs/config-catalog.i18n.yaml
  2. 1 1
      docs/config-catalog.md
  3. 1 1
      docs/config-catalog.zh.md
  4. 2 2
      docs/event-producer-consumer.i18n.yaml
  5. 1 1
      docs/event-producer-consumer.md
  6. 1 1
      docs/event-producer-consumer.zh.md
  7. 4 6
      packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
  8. 1 4
      packages/api/session-controller/tests/control-jobs.host.spec.ts
  9. 7 10
      packages/api/session-controller/tests/control-queue.host.spec.ts
  10. 6 8
      packages/api/session-controller/tests/session-projections.host.spec.ts
  11. 11 14
      packages/bundle/headless/tests/headless.spec.ts
  12. 24 15
      packages/context/agent-instructions/tests/agent-instructions.spec.ts
  13. 2 2
      packages/core/agent-loop/README.i18n.yaml
  14. 2 2
      packages/core/agent-loop/README.md
  15. 2 2
      packages/core/agent-loop/README.zh.md
  16. 0 2
      packages/core/agent-loop/src/index.ts
  17. 4 7
      packages/goal/command-goal/tests/command-goal.spec.ts
  18. 3 6
      packages/goal/goal/tests/goal.spec.ts
  19. 11 9
      packages/goal/tool-goal/tests/tool-goal.spec.ts
  20. 2 2
      packages/test-support/agent-loop-testkit/README.i18n.yaml
  21. 14 8
      packages/test-support/agent-loop-testkit/README.md
  22. 14 8
      packages/test-support/agent-loop-testkit/README.zh.md
  23. 4 1
      packages/test-support/agent-loop-testkit/package.json
  24. 132 1
      packages/test-support/agent-loop-testkit/src/inbox.ts
  25. 8 3
      packages/test-support/agent-loop-testkit/src/index.ts
  26. 56 1
      packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts
  27. 4 0
      pnpm-lock.yaml

+ 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: 12e50800ac32604088f3b0922c1006b0d050daf3
-config-catalog.zh.md: bb39c05f512011be6cd639f5b7938aebf939223a
+config-catalog.md: 85aead7a93bd8dff0da4eb4f0e6b6b348d9616b9
+config-catalog.zh.md: f91ebc6c46989cbf98d2a773f6f2873539ad6351

+ 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:313`](../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:311`](../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:313`](../packages/core/agent-loop/src/index.ts)
+来源:[`packages/core/agent-loop/src/index.ts:311`](../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: 676d7e5db42e1db96bc6e15563e443bb3d676d8e
-event-producer-consumer.zh.md: c42529a9f35dca9475ad44a8eefd7d83dbab5a97
+event-producer-consumer.md: a47c1df5aca5cc79cfda6cffde53e860a88b784b
+event-producer-consumer.zh.md: 5baa32146e24b8b155ccb2692282846b63f37937

+ 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:241`](../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:239`](../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:80`](../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:241`](../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:239`](../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:80`](../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) |

+ 4 - 6
packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts

@@ -1,5 +1,5 @@
 import { Context } from '@deepseek-ai/cordis'
-import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { Agent, Inbox, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
 import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
@@ -10,8 +10,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import { describe, expect, it, vi } from 'vitest'
 import { ApiSessionAgentController } from '../src/agent.ts'
 import { SessionCommandController } from '../src/commands.ts'
-import { ReactLoopInbox } from '@deepseek-ai/dsh-agent-loop'
-import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
 import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
 
 async function commandHarness(): Promise<{
@@ -27,13 +26,14 @@ async function commandHarness(): Promise<{
   await ctx.plugin(SessionProjectionRegistry)
   await ctx.plugin(AgentRegistry)
   const session = ctx.sessions.create(SessionId('commands-session'), { meta: { cwd: '/workspace' } })
+  const { inbox } = createInboxFixture(ctx.sessionProjections, session)
   const steer = vi.fn()
   const cancel = vi.fn()
   const agent: Agent = {
     id: session.id,
     options: {},
     session,
-    inbox: unsupportedInbox(),
+    inbox,
     status: 'running',
     ctx,
     send: () => {},
@@ -44,8 +44,6 @@ async function commandHarness(): Promise<{
     runMaintenance: task => task(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
-  Object.assign(agent, { inbox })
   ctx.agents.register(agent)
   ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
   ctx.provide('agentDefaultModel', {

+ 1 - 4
packages/api/session-controller/tests/control-jobs.host.spec.ts

@@ -1,5 +1,5 @@
 import { Context } from '@deepseek-ai/cordis'
-import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { JobOutcome } from '@deepseek-ai/dsh-jobs'
 import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
@@ -9,7 +9,6 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import { describe, expect, it } from 'vitest'
 import { SessionControlController } from '../src/control.ts'
 import type { SessionControlFrame } from '../src/types.ts'
-import { ReactLoopInbox } from '@deepseek-ai/dsh-agent-loop'
 import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
 
 type BaselineFrame = Extract<SessionControlFrame, { type: 'baseline' }>
@@ -60,8 +59,6 @@ async function harness(withJobs: boolean): Promise<{
     runMaintenance: task => task(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
-  Object.assign(agent, { inbox })
   ctx.agents.register(agent)
   const control = new SessionControlController(ctx)
   await new Promise(resolve => setTimeout(resolve, 0))

+ 7 - 10
packages/api/session-controller/tests/control-queue.host.spec.ts

@@ -1,5 +1,5 @@
 import { Context } from '@deepseek-ai/cordis'
-import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { Agent, Inbox } from '@deepseek-ai/dsh-agent'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -7,8 +7,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import { describe, expect, it } from 'vitest'
 import { SessionControlController } from '../src/control.ts'
 import type { SessionControlFrame } from '../src/types.ts'
-import { ReactLoopInbox } from '@deepseek-ai/dsh-agent-loop'
-import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
 
 async function harness(): Promise<{
   ctx: Context
@@ -21,13 +20,12 @@ async function harness(): Promise<{
   await ctx.plugin(SessionProjectionRegistry)
   await ctx.plugin(AgentRegistry)
   const session = ctx.sessions.create(SessionId('queue-session'))
+  const { inbox } = createInboxFixture(ctx.sessionProjections, session)
   const agent: Agent = {
-    id: session.id, options: {}, session, inbox: unsupportedInbox(), status: 'running', ctx,
+    id: session.id, options: {}, session, inbox, status: 'running', ctx,
     send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel: () => {},
     runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(),
   }
-  const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
-  Object.assign(agent, { inbox })
   ctx.agents.register(agent)
   return { ctx, control: new SessionControlController(ctx), agent, inbox }
 }
@@ -95,20 +93,19 @@ describe('Session control queue projection', () => {
     await ctx.plugin(AgentRegistry)
     const control = new SessionControlController(ctx)
     const session = ctx.sessions.create(SessionId('late-projection-queue'))
+    const { inbox } = createInboxFixture(ctx.sessionProjections, session)
     const agent: Agent = {
-      id: session.id, options: {}, session, inbox: unsupportedInbox(), status: 'running', ctx,
+      id: session.id, options: {}, session, inbox, status: 'running', ctx,
       send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel: () => {},
       runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(),
     }
-    const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
-    Object.assign(agent, { inbox })
     ctx.agents.register(agent)
     const abort = new AbortController()
     const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
     await iterator.next()
     const pending = message('late projection')
 
-    agent.inbox.append('next-turn', pending)
+    inbox.append('next-turn', pending)
 
     await expect(iterator.next()).resolves.toMatchObject({
       value: {

+ 6 - 8
packages/api/session-controller/tests/session-projections.host.spec.ts

@@ -13,10 +13,10 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import { z } from 'zod'
-import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
 import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
 import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
-import type { Agent } from '@deepseek-ai/dsh-agent'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session'
@@ -27,8 +27,7 @@ import Storage from '@deepseek-ai/dsh-storage'
 import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
 import * as StorageJson from '@deepseek-ai/dsh-storage-json'
 import type { SessionControlFrame, SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
-import { ReactLoopInbox } from '@deepseek-ai/dsh-agent-loop'
-import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
 import { createSessionTestRemote, testSessionPersistence, type TestSessionRemote } from './test-remote.ts'
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
@@ -127,18 +126,17 @@ async function harness(withRegistry: boolean): Promise<{
       claim: () => { throw new Error('inbox is unavailable without the projection registry') },
     }
   }
+  const fixture = createInboxFixture(ctx.sessionProjections, session)
   const agent: Agent = {
-    id: session.id, options: {}, session, inbox: unsupportedInbox(), status: 'idle', ctx,
+    id: session.id, options: {}, session, inbox: fixture.inbox, status: 'idle', ctx,
     send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel: () => {},
     runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(),
   }
-  const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
-  Object.assign(agent, { inbox })
   ctx.agents.register(agent)
   return {
     ctx,
     session,
-    claim: target => inbox.claim(target, 0),
+    claim: fixture.claim,
   }
 }
 

+ 11 - 14
packages/bundle/headless/tests/headless.spec.ts

@@ -2,15 +2,14 @@
 
 import { afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
 import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model'
 import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
 import SessionStore from '@deepseek-ai/dsh-session'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
-import { ReactLoopInbox } from '@deepseek-ai/dsh-agent-loop'
-import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
 import { apply, Config, internals } from '../src/index.ts'
 
 const originalInternals = { ...internals }
@@ -69,34 +68,32 @@ async function bench(script: Script): Promise<{
       const session = ctx.sessions.create(options.sessionId, {
         ...options.meta === undefined ? {} : { meta: options.meta },
       })
+      const fixture = createInboxFixture(ctx.sessionProjections, session)
       let idle = Promise.resolve()
       const agent: Agent = {
         id: session.id,
         options: options.agentOptions ?? {},
         session,
-        inbox: unsupportedInbox(),
+        inbox: fixture.inbox,
         status: 'idle',
         ctx: ownerCtx,
         cancel: () => {},
         runMaintenance: () => Promise.reject(new Error('not used')),
         send: () => {},
-        followup: () => { throw new Error('scripted Agent Inbox is not initialized') },
+        followup: (message: UserMessage) => {
+          fixture.inbox.append('next-turn', message)
+          const claimed = fixture.claim('next-turn')
+          const [prompt] = claimed
+          if (prompt === undefined || claimed.length !== 1) throw new Error('scripted Agent expected one claimed prompt')
+          idle = Promise.resolve().then(() => script.afterPrompt(session, prompt))
+        },
         steer: () => {},
         inject: () => {},
         whenIdle: () => idle,
       }
       const agentCtx = ownerCtx.extend({ agent })
-      const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
       Object.assign(agent, {
-        inbox,
         ctx: agentCtx,
-        followup: (message: UserMessage) => {
-          inbox.append('next-turn', message)
-          const claimed = inbox.claim('next-turn', 1)
-          const [prompt] = claimed
-          if (prompt === undefined || claimed.length !== 1) throw new Error('scripted Agent expected one claimed prompt')
-          idle = Promise.resolve().then(() => script.afterPrompt(session, prompt))
-        },
       })
       await options.setup?.(agentCtx)
       script.before?.(session)

+ 24 - 15
packages/context/agent-instructions/tests/agent-instructions.spec.ts

@@ -8,7 +8,7 @@ import * as workspaceContext from '@deepseek-ai/dsh-agent-instructions'
 import LlmRuntime, { createUserMessage, ToolCallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session'
 import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
-import AgentLoop, { ReactLoopInbox, turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
 import type {
@@ -43,7 +43,10 @@ import {
 import { resolveConfig } from '../src/config.ts'
 import { candidateScopeKey, renderInstructionChanges, renderWorkspaceInstructionSet, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE } from '../src/render.ts'
 import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
-import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
+import {
+  createInboxFixture,
+  type InboxFixture,
+} from '@deepseek-ai/dsh-agent-loop-testkit'
 
 /** Per-candidate reconciliation scope key: directory paired with the file name. */
 const sk = (directory: string, candidateName: string): string => candidateScopeKey(directory, candidateName)
@@ -55,8 +58,14 @@ await isolatedInboxCtx.plugin(SessionProjectionRegistry)
 await isolatedInboxCtx.plugin(AgentRegistry)
 let nextStubSession = 1
 
-interface TestAgent extends Agent {
-  readonly inbox: ReactLoopInbox
+type TestAgent = Agent
+const inboxFixtures = new WeakMap<Agent, InboxFixture>()
+
+/** Return the loop-driver operations paired with one structural test Agent. */
+function inboxFixture(agent: Agent): InboxFixture {
+  const fixture = inboxFixtures.get(agent)
+  if (fixture === undefined) throw new Error('agent Inbox fixture is unavailable')
+  return fixture
 }
 const requestTimeoutMs = process.platform === 'win32' ? 5_000 : 1_000
 
@@ -205,12 +214,13 @@ function stubAgent(cwd?: string, seed: readonly SessionEvent[] = []): TestAgent
     seed,
     ...cwd === undefined ? {} : { meta: { createdAt: 0, cwd } },
   })
-  const agent: Agent = {
+  const fixture = createInboxFixture(agentCtx.sessionProjections, session)
+  const agent: TestAgent = {
     ctx: agentCtx,
     id: SessionId('a1'),
     options: {},
     session,
-    inbox: unsupportedInbox(),
+    inbox: fixture.inbox,
     status: 'idle',
     send: () => {},
     followup: () => {},
@@ -220,9 +230,8 @@ function stubAgent(cwd?: string, seed: readonly SessionEvent[] = []): TestAgent
     runMaintenance: task => task(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  return Object.assign(agent, {
-    inbox: new ReactLoopInbox(agentCtx.sessionProjections, session, agentEvents(agentCtx, agent)),
-  })
+  inboxFixtures.set(agent, fixture)
+  return agent
 }
 
 function stubToolExecution(
@@ -273,7 +282,7 @@ function baselineEvents(agent: Agent): SessionEvent[] {
 async function appendAdditionalContexts(ctx: Context, agent: TestAgent): Promise<number | undefined> {
   await syncedWorkspaceContext(ctx, agent)
   let lastSeq: number | undefined
-  for (const claimed of agent.inbox.claim('next-step', 1)) {
+  for (const claimed of inboxFixture(agent).claim('next-step')) {
     if (claimed.source.kind !== 'agent-instructions') continue
     const event = agent.session.append('user/message', claimed, { surfaceOp: 'append' })
     ctx.emit('session/event', agent.session, event)
@@ -291,7 +300,7 @@ async function composeBaselinePrefix(ctx: Context, agent: TestAgent): Promise<Me
     { messages: [], turn: 1, step: 1, signal },
     () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
   )
-  const claimed = agent.inbox.claim('next-step', 1)
+  const claimed = inboxFixture(agent).claim('next-step')
   const decision = await agentEvents(ctx, agent).waterfall(
     'agent/pre-step',
     { messages: claimed, turn: 1, step: 2, signal },
@@ -1404,7 +1413,7 @@ describe('workspace context request injection', () => {
       await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
       const resumed = stubAgent(root, original.session.snapshotEvents())
       agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
-      const claimed = resumed.inbox.claim('next-step', 1)
+      const claimed = inboxFixture(resumed).claim('next-step')
       const decision = await agentEvents(ctx, resumed).waterfall(
         'agent/pre-step',
         { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
@@ -1450,7 +1459,7 @@ describe('workspace context request injection', () => {
       await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 })
       const resumed = stubAgent(root, original.session.snapshotEvents())
       agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
-      const staleClaim = resumed.inbox.claim('next-step', 1)
+      const staleClaim = inboxFixture(resumed).claim('next-step')
       const staleDecision = await agentEvents(ctx, resumed).waterfall(
         'agent/pre-step',
         { messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
@@ -1503,7 +1512,7 @@ describe('workspace context request injection', () => {
       await mountWorkspaceContextPlugin(resumedCtx, { dshHome: home, maxBytes })
       const resumed = stubAgent(root, original.session.snapshotEvents())
       agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' })
-      const claimed = resumed.inbox.claim('next-step', 1)
+      const claimed = inboxFixture(resumed).claim('next-step')
       const decision = await agentEvents(resumedCtx, resumed).waterfall(
         'agent/pre-step',
         { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
@@ -4658,7 +4667,7 @@ describe('workspace context inbox synchronization', () => {
       await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(join(root, 'pkg'))
       await syncedWorkspaceContext(ctx, agent)
-      const claimed = agent.inbox.claim('next-step', 1)
+      const claimed = inboxFixture(agent).claim('next-step')
       await write(join(root, 'pkg/AGENTS.md'), 'new claimed rule with more detail')
       const downstream = { kind: 'enter' as const, messages: claimed }
 

+ 2 - 2
packages/core/agent-loop/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-loop/README.md
-README.md: 4466fb4ff074e1266b4ee1cd1ef427f8192de1e3
-README.zh.md: 50259f1fa553deb9ffcb63edc2ce0cf85e4474ba
+README.md: e6fb94a51200b37af559b516e60acda512859ad6
+README.zh.md: 8048691399b849d05534db5d64f232c7c282aa25

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

@@ -98,7 +98,7 @@ After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields an
 |---|---|
 | [`src/index.ts`](src/index.ts) | Plugin entry: `AgentLoop` service, config schema, declarative agent startup, factory registration |
 | [`src/agent.ts`](src/agent.ts) | The concrete `ReactLoopAgent` driver: inbox, turn/step machine, cancellation |
-| [`src/inbox.ts`](src/inbox.ts) | Exported `ReactLoopInbox`: durable projection, structural commands, and loop-only claim state |
+| [`src/inbox.ts`](src/inbox.ts) | Package-internal `ReactLoopInbox`: durable projection, structural commands, and loop-only claim state |
 | [`src/tool-calls.ts`](src/tool-calls.ts) | Tool scheduling: exclusive barriers and the bounded parallel pool |
 | [`src/runtime-context.ts`](src/runtime-context.ts) | Per-step runtime-context snapshot handling |
 | [`src/constants.ts`](src/constants.ts) | `DEFAULT_MAX_PARALLEL_TOOL_CALLS` |
@@ -110,7 +110,7 @@ Creation is one rollback-covered transaction: construct a private session, concr
 
 ### Turn and step flow
 
-The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Its `ReactLoopInbox` constructor registers the standard `inbox` projection on the agent scope, then uses that projection for structural commands and loop-only claims; focused consumer tests construct the exported class to exercise the same implementation. Registry reference counting keeps the shared key active until the last agent scope unloads. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. `agent/pre-step` decides what enters the step. An entered decision appends its complete `user/message` batch before the driver can claim again, while a rejected decision appends none. Each successful model call appends one `assistant/message` anchor citing its chunk seqs, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered.
+The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Its package-internal `ReactLoopInbox` constructor registers the standard `inbox` projection on the agent scope, then uses that projection for structural commands and loop-only claims. Registry reference counting keeps the shared key active until the last agent scope unloads. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. `agent/pre-step` decides what enters the step. An entered decision appends its complete `user/message` batch before the driver can claim again, while a rejected decision appends none. Each successful model call appends one `assistant/message` anchor citing its chunk seqs, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered.
 
 ### Failure and cancellation
 

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

@@ -98,7 +98,7 @@ const handle = await ctx.agents.create({
 |---|---|
 | [`src/index.ts`](src/index.ts) | 插件入口:`AgentLoop` 服务、配置 schema、声明式 agent 启动、工厂注册 |
 | [`src/agent.ts`](src/agent.ts) | 具体 `ReactLoopAgent` 驱动器:收件箱、轮次/步骤状态机、取消 |
-| [`src/inbox.ts`](src/inbox.ts) | 导出的 `ReactLoopInbox`:持久投影、结构化命令与仅供循环使用的领取状态 |
+| [`src/inbox.ts`](src/inbox.ts) | 包内部的 `ReactLoopInbox`:持久投影、结构化命令与仅供循环使用的领取状态 |
 | [`src/tool-calls.ts`](src/tool-calls.ts) | 工具调度:独占屏障与有界并行池 |
 | [`src/runtime-context.ts`](src/runtime-context.ts) | 每步骤 runtime-context 快照处理 |
 | [`src/constants.ts`](src/constants.ts) | `DEFAULT_MAX_PARALLEL_TOOL_CALLS` |
@@ -110,7 +110,7 @@ const handle = await ctx.agents.create({
 
 ### 轮次与步骤流程
 
-驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。其 `ReactLoopInbox` 构造函数在 agent 作用域上注册标准 `inbox` 投影,随后将该投影用于结构化命令与仅供 loop 使用的领取操作;聚焦消费方的测试会构造这个导出的类,以运行同一份实现。注册表引用计数会使共享 key 持续有效,直至最后一个 agent 作用域卸载。在轮次边界,驱动器先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。`agent/pre-step` 决定什么进入该步骤。进入步骤的决定会在驱动器再次领取消息前追加完整的 `user/message` 批次,被拒绝的决定则不追加任何消息。每次成功的模型调用都恰好追加一个引用其分片 seq 的 `assistant/message` 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。
+驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。其包内部 `ReactLoopInbox` 构造函数在 agent 作用域上注册标准 `inbox` 投影,随后将该投影用于结构化命令与仅供 loop 使用的领取操作。注册表引用计数会使共享 key 持续有效,直至最后一个 agent 作用域卸载。在轮次边界,驱动器先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。`agent/pre-step` 决定什么进入该步骤。进入步骤的决定会在驱动器再次领取消息前追加完整的 `user/message` 批次,被拒绝的决定则不追加任何消息。每次成功的模型调用都恰好追加一个引用其分片 seq 的 `assistant/message` 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。
 
 ### 失败与取消
 

+ 0 - 2
packages/core/agent-loop/src/index.ts

@@ -34,8 +34,6 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
 import { ReactLoopAgent } from './agent.ts'
 import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
 
-export { ReactLoopInbox, inboxProjectionDefinition } from './inbox.ts'
-
 /** Fiber states that cannot own or serve a new lifecycle. */
 const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
   FiberState.UNLOADING,

+ 4 - 7
packages/goal/command-goal/tests/command-goal.spec.ts

@@ -1,7 +1,7 @@
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
-import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
 import CommandRuntime from '@deepseek-ai/dsh-commands'
 import GoalService from '@deepseek-ai/dsh-goal'
@@ -9,8 +9,7 @@ import type { GoalRef } from '@deepseek-ai/dsh-goal'
 import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import * as commandGoal from '@deepseek-ai/dsh-command-goal'
-import { ReactLoopInbox } from '@deepseek-ai/dsh-agent-loop'
-import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
 
 interface Harness {
   readonly ctx: Context
@@ -23,12 +22,13 @@ interface Harness {
 function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
   // Store-created: the command executor durably logs lifecycle events on it.
   const session = ctx.sessions.create(SessionId(id))
+  const { inbox } = createInboxFixture(ctx.sessionProjections, session)
   let status: AgentStatus = 'idle'
   const agent: Agent = {
     id: session.id,
     options: {},
     session,
-    inbox: unsupportedInbox(),
+    inbox,
     ctx: new Context(),
     get status() { return status },
     send: () => {},
@@ -39,9 +39,6 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
     runMaintenance: task => task(new AbortController().signal),
     whenIdle() { return Promise.resolve() },
   }
-  Object.assign(agent, {
-    inbox: new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent)),
-  })
   return { agent, session }
 }
 

+ 3 - 6
packages/goal/goal/tests/goal.spec.ts

@@ -12,8 +12,7 @@ import GoalService, {
   foldGoal,
 } from '@deepseek-ai/dsh-goal'
 import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
-import { ReactLoopInbox } from '@deepseek-ai/dsh-agent-loop'
-import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
 
 interface StubAgent {
   agent: Agent
@@ -45,11 +44,12 @@ function stubAgentForSession(session: Session, suppliedCtx?: Context): StubAgent
   if (suppliedCtx === undefined) {
     agentCtx.sessions.enter(session)
   }
+  const { inbox } = createInboxFixture(agentCtx.sessionProjections, session)
   const agent: Agent = {
     id,
     options: {},
     session,
-    inbox: unsupportedInbox(),
+    inbox,
     ctx: agentCtx,
     status: 'idle',
     send: () => {},
@@ -60,9 +60,6 @@ function stubAgentForSession(session: Session, suppliedCtx?: Context): StubAgent
     runMaintenance: task => task(new AbortController().signal),
     whenIdle() { return Promise.resolve() },
   }
-  Object.assign(agent, {
-    inbox: new ReactLoopInbox(agentCtx.sessionProjections, session, agentEvents(agentCtx, agent)),
-  })
   const stub = {
     agent,
     session,

+ 11 - 9
packages/goal/tool-goal/tests/tool-goal.spec.ts

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
-import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
+import type { Agent, AgentStatus, Inbox } from '@deepseek-ai/dsh-agent'
 import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
 import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
 import type { GoalRef } from '@deepseek-ai/dsh-goal'
@@ -14,15 +14,18 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRuntime from '@deepseek-ai/dsh-tools'
 import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
 import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
-import { ReactLoopInbox } from '@deepseek-ai/dsh-agent-loop'
-import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
+import {
+  createInboxFixture,
+  type InboxFixture,
+} from '@deepseek-ai/dsh-agent-loop-testkit'
 
 const testToolSignal = new AbortController().signal
 
 interface StubAgent {
   readonly agent: Agent
   readonly session: Session
-  readonly inbox: ReactLoopInbox
+  readonly inbox: Inbox
+  readonly fixture: InboxFixture
   setStatus(status: AgentStatus): void
 }
 
@@ -40,12 +43,13 @@ function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): St
   if (suppliedCtx === undefined) {
     if (agentCtx.sessions.get(session.id) !== session) agentCtx.sessions.enter(session)
   }
+  const fixture = createInboxFixture(agentCtx.sessionProjections, session)
   let status: AgentStatus = 'running'
   const agent: Agent = {
     id: session.id,
     options: {},
     session,
-    inbox: unsupportedInbox(),
+    inbox: fixture.inbox,
     get status() { return status },
     ctx: agentCtx,
     send: () => {},
@@ -58,9 +62,7 @@ function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): St
     runMaintenance: task => task(new AbortController().signal),
     whenIdle() { return Promise.resolve() },
   }
-  const inbox = new ReactLoopInbox(agentCtx.sessionProjections, session, agentEvents(agentCtx, agent))
-  Object.assign(agent, { inbox })
-  return { agent, session, inbox, setStatus(value) { status = value } }
+  return { agent, session, inbox: fixture.inbox, fixture, setStatus(value) { status = value } }
 }
 
 /** Open one message-triggered turn with its accepted model-visible input. */
@@ -73,7 +75,7 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb
     source,
   })
   stub.agent.inbox.append('next-turn', message)
-  const claimed = stub.inbox.claim('next-turn', turn)
+  const claimed = stub.fixture.claim('next-turn')
   if (claimed.length === 0) throw new Error('expected queued turn input')
   stub.session.append('turn/start', { turn })
   for (const admitted of claimed) {

+ 2 - 2
packages/test-support/agent-loop-testkit/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/test-support/agent-loop-testkit/README.md
-README.md: 8aaa982f780ceca2502395ffc7dbbbc82e7695fb
-README.zh.md: ded8c9a4f60a5d1b8a32c4e8edbe6d292d5ef8f3
+README.md: 1bc4e31ac62c7a7de28f32c230bc7db97e47917e
+README.zh.md: 41345f9ab3a55efb022957afe9d310aafac40514

+ 14 - 8
packages/test-support/agent-loop-testkit/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Prerequisite mounting and fail-fast Inbox stubs for Agent and agent-loop tests."
+description: "Prerequisite mounting, session-backed structural Inbox fixtures, and fail-fast Inbox stubs for agent-loop tests."
 kind: "package-library"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-agent-loop-testkit` mounts the standard prerequisite services a test needs before loading the concrete `AgentLoop` — the LLM runtime, session store, session-projection registry, system-prompt registry, tool registry, and agent registry — in dependency order, with one call. The loop itself, adapters, optional plugins, agents, and teardown stay in the test's hands, so each scenario keeps its own load order and topology. It also provides a fail-fast unsupported Inbox placeholder for Agent stubs whose tests do not exercise pending input. Use the package when a test's subject is loop behavior rather than service wiring; tests that probe injection failures or partial topologies mount their dependencies directly. It registers no model-facing behavior of its own.
+`dsh-agent-loop-testkit` mounts the standard prerequisite services a test needs before loading the concrete `AgentLoop` — the LLM runtime, session store, session-projection registry, system-prompt registry, tool registry, and agent registry — in dependency order, with one call. The loop itself, adapters, optional plugins, agents, and teardown stay in the test's hands, so each scenario keeps its own load order and topology. It also provides a session-backed structural Inbox fixture for consumer tests and a fail-fast unsupported Inbox placeholder for stubs whose tests do not exercise pending input. Use the package when a test's subject is loop behavior rather than service wiring; tests that probe injection failures or partial topologies mount their dependencies directly. It registers no model-facing behavior of its own.
 
 ## Table of Contents
 
@@ -43,16 +43,20 @@ await ctx.plugin(AgentLoop, { agents: [] })
 
 The mounting helper activates the LLM, session, session-projection, system-prompt, tool, and agent services in dependency order and returns before the loop is mounted. System-prompt and tool-registry configuration can be forwarded through `options`; the helper provides no test defaults beyond those the services own.
 
-### Stub an Agent outside Inbox tests
+### Build structural Agent stubs
 
-Use `unsupportedInbox()` only when the test subject does not exercise pending Agent input. It exposes empty pending lists and throws on every mutation, so an unexpected Inbox dependency fails at its first write. Tests that exercise Inbox behavior construct `ReactLoopInbox` from `@deepseek-ai/dsh-agent-loop` instead.
+Use `createInboxFixture(ctx.sessionProjections, session)` when pending input belongs to the test. It returns an `inbox` for the Agent literal and a separate `claim` operation for the test driver. Create the fixture before the Agent literal so the object satisfies the required structural interface from construction onward. Use `unsupportedInbox()` only when the test subject does not exercise pending Agent input; it exposes empty pending lists and throws on every mutation, so an unexpected Inbox dependency fails at its first write.
 
 ```ts
-import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
 
+declare const ctx: import('@deepseek-ai/cordis').Context
+declare const session: Parameters<typeof createInboxFixture>[1]
+
+const fixture = createInboxFixture(ctx.sessionProjections, session)
 const agent = {
   // ...
-  inbox: unsupportedInbox(),
+  inbox: fixture.inbox,
 }
 ```
 
@@ -76,7 +80,7 @@ This section explains the design of the test utilities; the observable behavior
 
 ### Design
 
-`mountAgentLoopTestDependencies` mounts six service plugins in a fixed dependency order — LLM, session, session-projection registry, system-prompt registry, tool registry, then agent registry — and deliberately stops before `AgentLoop` itself, so the caller controls loop load order and the topology under test. [`src/inbox.ts`](src/inbox.ts) provides only the fail-fast unsupported placeholder; it does not reproduce the concrete Inbox algorithm. The mounting implementation lives in [`src/index.ts`](src/index.ts). No companion is published because this test-support package owns no production event stream or mutable data; consuming test suites exercise its behavior.
+`mountAgentLoopTestDependencies` mounts six service plugins in a fixed dependency order — LLM, session, session-projection registry, system-prompt registry, tool registry, then agent registry — and deliberately stops before `AgentLoop` itself, so the caller controls loop load order and the topology under test. [`src/inbox.ts`](src/inbox.ts) owns a test-only projection definition for the public durable Inbox event and state contract, the structural command facade and driver claim operation, and the fail-fast unsupported placeholder. It does not import the package-internal loop implementation. The mounting implementation lives in [`src/index.ts`](src/index.ts). No companion is published because this test-support package owns no production event stream or mutable data; consuming test suites exercise its behavior.
 
 </details>
 
@@ -112,7 +116,9 @@ None; this package neither assembles nor sends a provider request.
 These limits define what the utilities do not share. They are current package constraints, not a task backlog.
 
 - **Only the mandatory prerequisite spine is shared** — adapters, optional plugins, `AgentLoop`, agents, and context teardown remain caller-owned so scenario-specific ordering stays visible.
-- **The unsupported Inbox accepts no mutations** — use the concrete `ReactLoopInbox` whenever pending input is part of the test subject.
+- **The structural fixture emits durable session events only** — it does not reproduce live `agent/inbox/inserted`, `agent/inbox/claimed`, or `agent/inbox/discarded` notifications owned by the loop implementation.
+- **The structural fixture accepts trusted test events** — it does not repeat the production provider's persisted-splice validation; focused `agent-loop` tests own invalid-history coverage.
+- **The unsupported Inbox accepts no mutations** — use `createInboxFixture()` whenever pending input is part of the test subject.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 14 - 8
packages/test-support/agent-loop-testkit/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "为 Agent 与 agent-loop 测试提供先决依赖挂载和快速失败的 Inbox 桩。"
+description: "为 agent-loop 测试提供先决依赖挂载、基于会话的结构化 Inbox fixture 和快速失败的 Inbox 桩。"
 kind: "package-library"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-library"
 
 ## 概述
 
-`dsh-agent-loop-testkit` 为测试在加载具体 `AgentLoop` 之前所需的全部标准先决服务——LLM(大语言模型)运行时、会话存储、会话投影注册表、系统提示词注册表、工具注册表与 agent(智能体)注册表——按依赖顺序一键挂载。loop 本身、适配器、可选插件、agent 与清理仍由测试掌控,因此每个场景都保持自己的加载顺序与拓扑。它还为不测试待处理输入的 Agent 桩提供一个快速失败且不支持操作的 Inbox 占位值。当测试对象是 loop 行为而非服务接线时使用本包;针对注入失败或部分拓扑的测试会直接挂载其依赖。它自身不注册任何模型可见行为。
+`dsh-agent-loop-testkit` 为测试在加载具体 `AgentLoop` 之前所需的全部标准先决服务——LLM(大语言模型)运行时、会话存储、会话投影注册表、系统提示词注册表、工具注册表与 agent(智能体)注册表——按依赖顺序一键挂载。loop 本身、适配器、可选插件、agent 与清理仍由测试掌控,因此每个场景都保持自己的加载顺序与拓扑。它还为消费方测试提供基于会话的结构化 Inbox fixture,并为不测试待处理输入的桩提供一个快速失败且不支持操作的 Inbox 占位值。当测试对象是 loop 行为而非服务接线时使用本包;针对注入失败或部分拓扑的测试会直接挂载其依赖。它自身不注册任何模型可见行为。
 
 ## 目录
 
@@ -43,16 +43,20 @@ await ctx.plugin(AgentLoop, { agents: [] })
 
 挂载辅助函数按依赖顺序激活 LLM、会话、会话投影、系统提示词、工具与 agent 服务,并在 loop 挂载前返回。系统提示词与工具注册表配置可通过 `options` 转发;除服务自有的默认值外,本辅助函数不提供测试默认值。
 
-### 在 Inbox 测试之外为 Agent 提供桩
+### 构造结构化 Agent 桩
 
-仅当测试对象不涉及待处理的 Agent 输入时才使用 `unsupportedInbox()`。它公开空的待处理列表,并在每次变更时抛错,因此意外的 Inbox 依赖会在首次写入时失败。测试 Inbox 行为时,应改为从 `@deepseek-ai/dsh-agent-loop` 构造 `ReactLoopInbox`。
+当待处理输入属于测试对象时,使用 `createInboxFixture(ctx.sessionProjections, session)`。它会返回供 Agent 对象字面量使用的 `inbox`,以及供测试驱动使用的独立 `claim` 操作。应先创建 fixture,再构造 Agent 对象字面量,使对象从构造开始就满足必需的结构化接口。仅当测试对象不涉及待处理的 Agent 输入时才使用 `unsupportedInbox()`;它公开空的待处理列表,并在每次变更时抛错,因此意外的 Inbox 依赖会在首次写入时失败。
 
 ```ts
-import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
 
+declare const ctx: import('@deepseek-ai/cordis').Context
+declare const session: Parameters<typeof createInboxFixture>[1]
+
+const fixture = createInboxFixture(ctx.sessionProjections, session)
 const agent = {
   // ...
-  inbox: unsupportedInbox(),
+  inbox: fixture.inbox,
 }
 ```
 
@@ -76,7 +80,7 @@ const agent = {
 
 ### 设计
 
-`mountAgentLoopTestDependencies` 按固定依赖顺序——LLM、会话、会话投影注册表、系统提示词注册表、工具注册表、agent 注册表——挂载六个服务插件,并刻意在 `AgentLoop` 之前停下,使调用方控制 loop 加载顺序与待测拓扑。[`src/inbox.ts`](src/inbox.ts) 只提供快速失败且不支持操作的占位值,不会重现具体 Inbox 算法。挂载实现位于 [`src/index.ts`](src/index.ts)。本测试支持包不持有任何生产事件流或可变数据,因此不发布伴生入口;消费它的测试套件会直接检验其行为。
+`mountAgentLoopTestDependencies` 按固定依赖顺序——LLM、会话、会话投影注册表、系统提示词注册表、工具注册表、agent 注册表——挂载六个服务插件,并刻意在 `AgentLoop` 之前停下,使调用方控制 loop 加载顺序与待测拓扑。[`src/inbox.ts`](src/inbox.ts) 持有针对公开持久 Inbox 事件与状态约定的测试专用投影定义、结构化命令 facade、驱动方 claim 操作,以及快速失败且不支持操作的占位值。它不会导入包内部的 loop 实现。挂载实现位于 [`src/index.ts`](src/index.ts)。本测试支持包不持有任何生产事件流或可变数据,因此不发布伴生入口;消费它的测试套件会直接检验其行为。
 
 </details>
 
@@ -112,7 +116,9 @@ const agent = {
 这些限制说明辅助工具不共享什么。它们是当前包约束,不是任务积压。
 
 - **只共享必需的先决主干**——适配器、可选插件、`AgentLoop`、agent 与上下文清理仍由调用方负责,以使特定场景的挂载顺序清晰可见。
-- **不支持操作的 Inbox 不接受变更**——只要待处理输入属于测试对象,就应使用具体 `ReactLoopInbox`。
+- **结构化 fixture 只发出持久会话事件**——它不会复现由 loop 实现持有的实时 `agent/inbox/inserted`、`agent/inbox/claimed` 或 `agent/inbox/discarded` 通知。
+- **结构化 fixture 接受受信的测试事件**——它不会重复生产 provider 的持久 splice 校验;无效历史覆盖由聚焦的 `agent-loop` 测试持有。
+- **不支持操作的 Inbox 不接受变更**——只要待处理输入属于测试对象,就应使用 `createInboxFixture()`。
 
 <a id="dev-note"></a>
 ### 开发备注

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

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-agent-loop-testkit",
-  "description": "Prerequisite mounting and fail-fast Inbox stubs for Agent and agent-loop tests",
+  "description": "Prerequisite mounting and session-backed Inbox fixtures for agent-loop tests",
   "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
@@ -35,6 +35,9 @@
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   },
+  "dependencies": {
+    "zod": "^4.4.3"
+  },
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent-loop": "workspace:^",

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

@@ -1,4 +1,135 @@
-import type { Inbox } from '@deepseek-ai/dsh-agent'
+import type { Inbox, InboxState, InboxTarget, InboxWireState } from '@deepseek-ai/dsh-agent'
+import type { MessageId } from '@deepseek-ai/dsh-llm'
+import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session'
+import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
+import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { z } from 'zod'
+
+const testInboxProjectionSchema = z.object({
+  'next-turn': z.array(z.custom<UserMessage>()).readonly(),
+  'next-step': z.array(z.custom<UserMessage>()).readonly(),
+}).readonly()
+
+/** Test-only registration for the public durable Inbox event and state contract. */
+const testInboxProjectionDefinition = {
+  key: 'inbox',
+  stateSchema: testInboxProjectionSchema,
+  init: (): InboxState => ({ 'next-turn': [], 'next-step': [] }),
+  apply(state: InboxState, event) {
+    if (event.type !== 'agent/inbox/spliced') return state
+    const { target, start, removedCount = 0, inserted } = event.data
+    const next = [...state[target]]
+    next.splice(start, removedCount, ...inserted)
+    return { ...state, [target]: next }
+  },
+  wire: {
+    viewSchema: testInboxProjectionSchema as unknown as z.ZodType<InboxWireState>,
+    view: (state: InboxState) => state as unknown as InboxWireState,
+  },
+  stateVersion: 1,
+} satisfies ProjectionDefinition<'inbox', InboxState>
+
+/** A structural Inbox test double and its loop-driver operation. */
+export interface InboxFixture {
+  /** Session-backed Inbox exposed to the code under test. */
+  readonly inbox: Inbox
+  /** Remove the batch a test driver admits at one boundary. */
+  readonly claim: (target: InboxTarget) => UserMessage[]
+}
+
+/**
+ * Create a session-backed structural Inbox test double for consumer tests.
+ * @param projections - registry that owns the fixture's test projection registration.
+ * @param session - session whose durable splices back the test double.
+ * @returns the structural Inbox and a separate loop-driver claim operation.
+ */
+export function createInboxFixture(
+  projections: SessionProjectionRegistry,
+  session: Session,
+): InboxFixture {
+  projections.register(testInboxProjectionDefinition)
+
+  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 projection registration is not active')
+    return state
+  }
+
+  const locate = (messageId: MessageId): { target: InboxTarget; index: number } | undefined => {
+    const state = current()
+    const turnIndex = state['next-turn'].findIndex(message => message.id === messageId)
+    if (turnIndex >= 0) return { target: 'next-turn', index: turnIndex }
+    const stepIndex = state['next-step'].findIndex(message => message.id === messageId)
+    return stepIndex < 0 ? undefined : { target: 'next-step', index: stepIndex }
+  }
+
+  const mutate = (
+    target: InboxTarget,
+    start: number,
+    deleteCount: number,
+    inserted: UserMessage[],
+    canceled: boolean,
+  ): UserMessage[] => {
+    const pending = current()[target]
+    const integerStart = Number.isNaN(start) ? 0 : Math.trunc(start)
+    const index = integerStart < 0
+      ? Math.max(pending.length + integerStart, 0)
+      : Math.min(integerStart, pending.length)
+    const integerCount = Number.isNaN(deleteCount) ? 0 : Math.trunc(deleteCount)
+    const count = Math.min(Math.max(integerCount, 0), pending.length - index)
+    if (count === 0 && inserted.length === 0) return []
+    const event: SessionEventMap['agent/inbox/spliced'] = {
+      target,
+      start: index,
+      ...(count === 0 ? {} : { removedCount: count }),
+      inserted,
+      ...(canceled && count > 0 ? { outcome: 'canceled' } : {}),
+    }
+    const removed = pending.slice(index, index + count)
+    session.append('agent/inbox/spliced', event)
+    return removed
+  }
+
+  const inbox: Inbox = {
+    get nextTurn() { return current()['next-turn'] },
+    get nextStep() { return current()['next-step'] },
+    clear() {
+      mutate('next-step', 0, current()['next-step'].length, [], true)
+      mutate('next-turn', 0, current()['next-turn'].length, [], true)
+    },
+    append(target, message) {
+      mutate(target, current()[target].length, 0, [message], true)
+    },
+    prepend(target, message) {
+      mutate(target, 0, 0, [message], true)
+    },
+    replace(messageId, message) {
+      const location = locate(messageId)
+      if (location === undefined) return false
+      mutate(location.target, location.index, 1, [message], true)
+      return true
+    },
+    remove(messageId) {
+      const location = locate(messageId)
+      if (location === undefined) return false
+      mutate(location.target, location.index, 1, [], true)
+      return true
+    },
+    splice(target, start, deleteCount, inserted) {
+      return mutate(target, start, deleteCount, inserted, true)
+    },
+  }
+
+  return {
+    inbox,
+    claim: (target) => {
+      const claimed = mutate('next-step', 0, current()['next-step'].length, [], false)
+      if (target === 'next-turn') claimed.push(...mutate('next-turn', 0, 1, [], false))
+      return claimed
+    },
+  }
+}
 
 /**
  * Create an unsupported Inbox placeholder for Agent stubs whose tests do not exercise Inbox behavior.

+ 8 - 3
packages/test-support/agent-loop-testkit/src/index.ts

@@ -1,6 +1,7 @@
 /**
- * Shared service mounting for agent-loop tests. Callers retain ownership of
- * their contexts, loops, adapters, optional plugins, and teardown.
+ * Shared service mounting and session-backed Inbox fixtures for agent-loop
+ * tests. Callers retain ownership of their contexts, loops, adapters,
+ * optional plugins, and teardown.
  * @module @deepseek-ai/dsh-agent-loop-testkit
  */
 
@@ -14,7 +15,11 @@ import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-promp
 import ToolRuntime from '@deepseek-ai/dsh-tools'
 import type { Config as ToolRuntimeConfig } from '@deepseek-ai/dsh-tools'
 
-export { unsupportedInbox } from './inbox.ts'
+export {
+  createInboxFixture,
+  unsupportedInbox,
+  type InboxFixture,
+} from './inbox.ts'
 
 /** Configuration forwarded to the prerequisite service plugins. */
 export interface AgentLoopTestDependenciesOptions {

+ 56 - 1
packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts

@@ -1,8 +1,19 @@
 import { describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
-import { unsupportedInbox, mountAgentLoopTestDependencies } from '../src/index.ts'
+import {
+  createInboxFixture,
+  mountAgentLoopTestDependencies,
+  unsupportedInbox,
+} from '../src/index.ts'
+
+function message(text: string) {
+  return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
+}
 
 describe('dsh-agent-loop-testkit', () => {
   it('rejects mutations through an unsupported Agent stub Inbox', () => {
@@ -25,4 +36,48 @@ describe('dsh-agent-loop-testkit', () => {
 
     await ctx.fiber.dispose()
   })
+
+  it('provides a session-backed structural Inbox with separate driver claims', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionProjectionRegistry)
+    const session = Session.create(SessionId('agent-loop-testkit-inbox'))
+    const fixture = createInboxFixture(ctx.sessionProjections, session)
+    const firstTurn = message('first turn')
+    const secondTurn = message('second turn')
+    const firstStep = message('first step')
+    const editedTurn = message('edited turn')
+    const editedStep = message('edited step')
+
+    fixture.inbox.append('next-turn', firstTurn)
+    fixture.inbox.prepend('next-turn', secondTurn)
+    fixture.inbox.append('next-step', firstStep)
+    expect(fixture.inbox.nextTurn).toEqual([secondTurn, firstTurn])
+    expect(fixture.inbox.nextStep).toEqual([firstStep])
+
+    expect(fixture.inbox.replace(firstTurn.id, editedTurn)).toBe(true)
+    expect(fixture.inbox.replace(firstStep.id, editedStep)).toBe(true)
+    expect(fixture.inbox.replace(firstTurn.id, message('missing replacement'))).toBe(false)
+    expect(fixture.inbox.remove(firstTurn.id)).toBe(false)
+    expect(fixture.inbox.splice('next-turn', -1, 1, [])).toEqual([editedTurn])
+    expect(fixture.inbox.remove(editedStep.id)).toBe(true)
+
+    const claimedStep = message('claimed step')
+    const claimedTurn = message('claimed turn')
+    fixture.inbox.splice('next-step', Number.NaN, Number.NaN, [claimedStep])
+    fixture.inbox.append('next-turn', claimedTurn)
+    expect(fixture.claim('next-step')).toEqual([claimedStep])
+    expect(fixture.claim('next-turn')).toEqual([secondTurn])
+    expect(fixture.inbox.nextTurn).toEqual([claimedTurn])
+
+    const eventCount = session.snapshotEvents().length
+    expect(fixture.inbox.splice('next-step', 100, -1, [])).toEqual([])
+    expect(session.snapshotEvents()).toHaveLength(eventCount)
+
+    fixture.inbox.clear()
+    expect(fixture.inbox.nextTurn).toEqual([])
+    expect(fixture.inbox.nextStep).toEqual([])
+    fixture.inbox.clear()
+
+    await ctx.fiber.dispose()
+  })
 })

+ 4 - 0
pnpm-lock.yaml

@@ -8919,6 +8919,10 @@ importers:
         version: link:../../core/tools
 
   packages/test-support/agent-loop-testkit:
+    dependencies:
+      zod:
+        specifier: ^4.4.3
+        version: 4.4.3
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^