Explorar o código

test(agent-loop): use production inbox harness

_Kerman hai 1 semana
pai
achega
dc160810da

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.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 .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md
-2026-07-31-claimed-pre-step-inbox-lifecycle.md: f6daaa97884c3d1ae6dd8cc9e300528389c834ae
-2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: e6218bb9aafb62dc4299b0398ac3a40be6d89d68
+2026-07-31-claimed-pre-step-inbox-lifecycle.md: 737e3835263a3215a0fd2e52dad4ee05402bd888
+2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: ecb731df663e0d48b374a3118d7db7f6a34bfc18

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md

@@ -36,7 +36,7 @@ The archived [addressable queue occurrence decision](../../archived/feature/2026
 
 ## Verification
 
-Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, cancellation, and agent-scope projection removal after the last owner unloads. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Generated event and type catalogs expose only the new waterfall and payloads.
+Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, cancellation, and agent-scope projection removal after the last owner unloads. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Consumer-domain tests use a process-local Inbox stub only when durability is outside the test subject; claiming, durable projection, recovery, validation, and live-notification tests create Agents through the production AgentLoop test harness, so test support never reimplements the projection. Generated event and type catalogs expose only the new waterfall and payloads.
 
 ## Consequences
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md

@@ -36,7 +36,7 @@ Status: implemented
 
 ## 验证
 
-agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败、取消,以及最后一个所有者卸载后移除 agent 作用域投影。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。生成的事件与类型目录只公开新的 waterfall 与载荷。
+agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败、取消,以及最后一个所有者卸载后移除 agent 作用域投影。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。只有当持久性不属于测试对象时,消费方领域测试才使用进程内 Inbox 桩;领取、持久投影、恢复、校验与实时通知测试通过生产 AgentLoop 测试 harness 创建 Agent,因此测试支持代码不会重新实现该投影。生成的事件与类型目录只公开新的 waterfall 与载荷。
 
 ## 后果
 

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

@@ -10,7 +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 { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
 import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
 
 async function commandHarness(): Promise<{
@@ -26,7 +26,7 @@ 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 inbox = createInboxStub()
   const steer = vi.fn()
   const cancel = vi.fn()
   const agent: Agent = {

+ 23 - 28
packages/api/session-controller/tests/control-queue.host.spec.ts

@@ -1,13 +1,20 @@
 import { Context } from '@deepseek-ai/cordis'
-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'
-import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
-import { describe, expect, it } from 'vitest'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { afterEach, describe, expect, it } from 'vitest'
 import { SessionControlController } from '../src/control.ts'
 import type { SessionControlFrame } from '../src/types.ts'
-import { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
+import {
+  mountAgentLoopTestDependencies,
+  mountAgentLoopTestHarness,
+} from '@deepseek-ai/dsh-agent-loop-testkit'
+
+const ownedContexts = new Set<Context>()
+afterEach(async () => {
+  await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose()))
+  ownedContexts.clear()
+})
 
 async function harness(): Promise<{
   ctx: Context
@@ -16,18 +23,11 @@ async function harness(): Promise<{
   inbox: Inbox
 }> {
   const ctx = new Context()
-  await ctx.plugin(SessionStore)
-  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, status: 'running', ctx,
-    send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel: () => {},
-    runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(),
-  }
-  ctx.agents.register(agent)
-  return { ctx, control: new SessionControlController(ctx), agent, inbox }
+  ownedContexts.add(ctx)
+  await mountAgentLoopTestDependencies(ctx)
+  const loop = await mountAgentLoopTestHarness(ctx)
+  const agent = loop.create(SessionId('queue-session'))
+  return { ctx, control: new SessionControlController(ctx), agent, inbox: agent.inbox }
 }
 
 function message(text: string, source: 'user' | 'plugin' = 'user') {
@@ -88,18 +88,12 @@ describe('Session control queue projection', () => {
 
   it('derives queue replacements from the completed projection regardless of registration order', async () => {
     const ctx = new Context()
-    await ctx.plugin(SessionStore)
-    await ctx.plugin(SessionProjectionRegistry)
-    await ctx.plugin(AgentRegistry)
+    ownedContexts.add(ctx)
+    await mountAgentLoopTestDependencies(ctx)
+    const loop = await mountAgentLoopTestHarness(ctx)
     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, status: 'running', ctx,
-      send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel: () => {},
-      runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(),
-    }
-    ctx.agents.register(agent)
+    const agent = loop.create(SessionId('late-projection-queue'))
+    const { inbox } = agent
     const abort = new AbortController()
     const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
     await iterator.next()
@@ -190,6 +184,7 @@ describe('Session control queue projection', () => {
     inbox.append('next-turn', second)
 
     const queues: Extract<SessionControlFrame, { type: 'queue' }>[] = []
+    ownedContexts.delete(ctx)
     await ctx.fiber.dispose()
     for (;;) {
       const next = await iterator.next()

+ 25 - 16
packages/api/session-controller/tests/session-projections.host.spec.ts

@@ -7,14 +7,13 @@
  * pushed through the control stream.
  */
 
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { mkdtemp, readFile, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import { z } from 'zod'
 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 { createUserMessage } from '@deepseek-ai/dsh-llm'
@@ -27,9 +26,19 @@ 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 { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
+import {
+  mountAgentLoopTestDependencies,
+  mountAgentLoopTestHarness,
+} from '@deepseek-ai/dsh-agent-loop-testkit'
 import { createSessionTestRemote, testSessionPersistence, type TestSessionRemote } from './test-remote.ts'
 
+const ownedContexts = new Set<Context>()
+afterEach(async () => {
+  await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose()))
+  ownedContexts.clear()
+})
+let nextHarnessSession = 1
+
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionStateMap {
     'test/last-user': LastUserState
@@ -115,28 +124,28 @@ async function harness(withRegistry: boolean): Promise<{
   readonly claim: (target: 'next-turn' | 'next-step') => UserMessage[]
 }> {
   const ctx = new Context()
-  await ctx.plugin(SessionStore)
-  await ctx.plugin(AgentRegistry)
-  if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
-  const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+  ownedContexts.add(ctx)
   if (!withRegistry) {
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
     return {
       ctx,
       session,
       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: fixture.inbox, status: 'idle', ctx,
-    send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel: () => {},
-    runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(),
-  }
-  ctx.agents.register(agent)
+  await mountAgentLoopTestDependencies(ctx)
+  const loop = await mountAgentLoopTestHarness(ctx)
+  const agent = loop.create(
+    SessionId(`session-projections-${String(nextHarnessSession++)}`),
+    {},
+    { cwd: '/workspace' },
+  )
   return {
     ctx,
-    session,
-    claim: fixture.claim,
+    session: agent.session,
+    claim: target => loop.claim(agent, target, 1),
   }
 }
 

+ 5 - 5
packages/bundle/headless/tests/headless.spec.ts

@@ -9,7 +9,7 @@ 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 { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
 import { apply, Config, internals } from '../src/index.ts'
 
 const originalInternals = { ...internals }
@@ -68,21 +68,21 @@ 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)
+      const inbox = createInboxStub()
       let idle = Promise.resolve()
       const agent: Agent = {
         id: session.id,
         options: options.agentOptions ?? {},
         session,
-        inbox: fixture.inbox,
+        inbox,
         status: 'idle',
         ctx: ownerCtx,
         cancel: () => {},
         runMaintenance: () => Promise.reject(new Error('not used')),
         send: () => {},
         followup: (message: UserMessage) => {
-          fixture.inbox.append('next-turn', message)
-          const claimed = fixture.claim('next-turn')
+          inbox.append('next-turn', message)
+          const claimed = inbox.splice('next-turn', 0, 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))

+ 36 - 40
packages/context/agent-instructions/tests/agent-instructions.spec.ts

@@ -1,12 +1,12 @@
 import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
 import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
 import { tmpdir } from 'node:os'
-import { describe, expect, it, vi } from 'vitest'
+import { afterAll, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 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 SessionStore, { SessionId, type SessionEvent, type SurfaceIntent, type UserMessage } from '@deepseek-ai/dsh-session'
 import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
 import AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
@@ -44,8 +44,8 @@ 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 {
-  createInboxFixture,
-  type InboxFixture,
+  mountAgentLoopTestDependencies,
+  mountAgentLoopTestHarness,
 } from '@deepseek-ai/dsh-agent-loop-testkit'
 
 /** Per-candidate reconciliation scope key: directory paired with the file name. */
@@ -53,19 +53,16 @@ const sk = (directory: string, candidateName: string): string => candidateScopeK
 
 const testToolSignal = new AbortController().signal
 const isolatedInboxCtx = new Context()
-await isolatedInboxCtx.plugin(SessionStore)
-await isolatedInboxCtx.plugin(SessionProjectionRegistry)
-await isolatedInboxCtx.plugin(AgentRegistry)
+await mountAgentLoopTestDependencies(isolatedInboxCtx)
+const isolatedAgentLoop = await mountAgentLoopTestHarness(isolatedInboxCtx)
 let nextStubSession = 1
+afterAll(() => isolatedInboxCtx.fiber.dispose())
 
 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
+/** Admit one test Agent's pending input through the production loop driver. */
+function claimInbox(agent: Agent, target: 'next-turn' | 'next-step'): UserMessage[] {
+  return isolatedAgentLoop.claim(agent, target, 1)
 }
 const requestTimeoutMs = process.platform === 'win32' ? 5_000 : 1_000
 
@@ -209,28 +206,27 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace
 
 function stubAgent(cwd?: string, seed: readonly SessionEvent[] = []): TestAgent {
   const id = SessionId(`agent-instructions-${String(nextStubSession++)}`)
-  const agentCtx = isolatedInboxCtx
-  const session = agentCtx.sessions.create(id, {
-    seed,
-    ...cwd === undefined ? {} : { meta: { createdAt: 0, cwd } },
-  })
-  const fixture = createInboxFixture(agentCtx.sessionProjections, session)
-  const agent: TestAgent = {
-    ctx: agentCtx,
-    id: SessionId('a1'),
-    options: {},
-    session,
-    inbox: fixture.inbox,
-    status: 'idle',
-    send: () => {},
-    followup: () => {},
-    steer: () => {},
-    inject: () => { throw new Error('agent-instructions must append directly to the open step') },
-    cancel() {},
-    runMaintenance: task => task(new AbortController().signal),
-    whenIdle: () => Promise.resolve(),
+  const agent = isolatedAgentLoop.create(
+    id,
+    {},
+    cwd === undefined ? {} : { cwd },
+  )
+  const append = agent.session.append.bind(agent.session) as unknown as (
+    type: SessionEvent['type'],
+    data: SessionEvent['data'],
+    opts?: Partial<SurfaceIntent>,
+  ) => SessionEvent
+  for (const event of seed) {
+    if ('surfaceOp' in event || 'sourceEventSeqs' in event) {
+      append(event.type, event.data, {
+        ...event.surfaceOp === undefined ? {} : { surfaceOp: event.surfaceOp },
+        ...event.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: event.sourceEventSeqs },
+      })
+    } else {
+      append(event.type, event.data)
+    }
   }
-  inboxFixtures.set(agent, fixture)
+  if (seed.at(-1)?.type !== 'session/end-seed') agent.session.append('session/end-seed', {})
   return agent
 }
 
@@ -282,7 +278,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 inboxFixture(agent).claim('next-step')) {
+  for (const claimed of claimInbox(agent, '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)
@@ -300,7 +296,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 = inboxFixture(agent).claim('next-step')
+  const claimed = claimInbox(agent, 'next-step')
   const decision = await agentEvents(ctx, agent).waterfall(
     'agent/pre-step',
     { messages: claimed, turn: 1, step: 2, signal },
@@ -1413,7 +1409,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 = inboxFixture(resumed).claim('next-step')
+      const claimed = claimInbox(resumed, 'next-step')
       const decision = await agentEvents(ctx, resumed).waterfall(
         'agent/pre-step',
         { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
@@ -1459,7 +1455,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 = inboxFixture(resumed).claim('next-step')
+      const staleClaim = claimInbox(resumed, 'next-step')
       const staleDecision = await agentEvents(ctx, resumed).waterfall(
         'agent/pre-step',
         { messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
@@ -1512,7 +1508,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 = inboxFixture(resumed).claim('next-step')
+      const claimed = claimInbox(resumed, 'next-step')
       const decision = await agentEvents(resumedCtx, resumed).waterfall(
         'agent/pre-step',
         { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) },
@@ -4667,7 +4663,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 = inboxFixture(agent).claim('next-step')
+      const claimed = claimInbox(agent, '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/goal/command-goal/tests/command-goal.spec.ts

@@ -9,7 +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 { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
 
 interface Harness {
   readonly ctx: Context
@@ -22,7 +22,7 @@ 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)
+  const inbox = createInboxStub()
   let status: AgentStatus = 'idle'
   const agent: Agent = {
     id: session.id,

+ 2 - 2
packages/goal/goal/tests/goal.spec.ts

@@ -12,7 +12,7 @@ import GoalService, {
   foldGoal,
 } from '@deepseek-ai/dsh-goal'
 import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
-import { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
 
 interface StubAgent {
   agent: Agent
@@ -44,7 +44,7 @@ function stubAgentForSession(session: Session, suppliedCtx?: Context): StubAgent
   if (suppliedCtx === undefined) {
     agentCtx.sessions.enter(session)
   }
-  const { inbox } = createInboxFixture(agentCtx.sessionProjections, session)
+  const inbox = createInboxStub()
   const agent: Agent = {
     id,
     options: {},

+ 6 - 10
packages/goal/tool-goal/tests/tool-goal.spec.ts

@@ -14,10 +14,7 @@ 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 {
-  createInboxFixture,
-  type InboxFixture,
-} from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
 
 const testToolSignal = new AbortController().signal
 
@@ -25,7 +22,6 @@ interface StubAgent {
   readonly agent: Agent
   readonly session: Session
   readonly inbox: Inbox
-  readonly fixture: InboxFixture
   setStatus(status: AgentStatus): void
 }
 
@@ -34,7 +30,7 @@ await isolatedInboxCtx.plugin(SessionStore)
 await isolatedInboxCtx.plugin(SessionProjectionRegistry)
 await isolatedInboxCtx.plugin(AgentRegistry)
 
-/** Build one registry-compatible live agent whose injections enter the durable inbox. */
+/** Build one registry-compatible live agent whose injections enter its test Inbox. */
 function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): StubAgent {
   const agentCtx = suppliedCtx ?? isolatedInboxCtx
   const session = supplied ?? (suppliedCtx === undefined
@@ -43,13 +39,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)
+  const inbox = createInboxStub()
   let status: AgentStatus = 'running'
   const agent: Agent = {
     id: session.id,
     options: {},
     session,
-    inbox: fixture.inbox,
+    inbox,
     get status() { return status },
     ctx: agentCtx,
     send: () => {},
@@ -62,7 +58,7 @@ function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): St
     runMaintenance: task => task(new AbortController().signal),
     whenIdle() { return Promise.resolve() },
   }
-  return { agent, session, inbox: fixture.inbox, fixture, setStatus(value) { status = value } }
+  return { agent, session, inbox, setStatus(value) { status = value } }
 }
 
 /** Open one message-triggered turn with its accepted model-visible input. */
@@ -75,7 +71,7 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb
     source,
   })
   stub.agent.inbox.append('next-turn', message)
-  const claimed = stub.fixture.claim('next-turn')
+  const claimed = stub.inbox.splice('next-turn', 0, 1, [])
   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: 1bc4e31ac62c7a7de28f32c230bc7db97e47917e
-README.zh.md: 41345f9ab3a55efb022957afe9d310aafac40514
+README.md: cb71e8cda9560dd432fa818e210df58c33c0fb9d
+README.zh.md: c93ae5ccf020650b00379c12515c431933359168

+ 36 - 31
packages/test-support/agent-loop-testkit/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Prerequisite mounting, session-backed structural Inbox fixtures, and fail-fast Inbox stubs for agent-loop tests."
+description: "Prerequisite mounting, production AgentLoop drivers, and explicit 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 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.
+`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. A second helper mounts the production loop and returns a narrow driver for creating real Agents and claiming their real Inbox input. Consumer tests that need only the public queue operations can instead use an explicitly process-local Inbox stub, while tests with no pending-input behavior can use a fail-fast unsupported Inbox. Adapters, optional plugins, load order, and teardown stay in the test's hands. The package registers no model-facing behavior of its own.
 
 ## Table of Contents
 
@@ -25,48 +25,54 @@ English | [中文](README.zh.md)
 <a id="use-this-package"></a>
 ## Use this package
 
-This package gives an AgentLoop test a working service topology before the loop is mounted.
+This package gives an AgentLoop test a working service topology and keeps the choice between production Inbox behavior and a structural stub explicit.
 
-### Minimal example
+### Drive a production Agent
+
+Use `mountAgentLoopTestHarness()` when the test covers durable Inbox events, projection recovery or validation, live Inbox notifications, or loop-driver claims. Mount any load-order-sensitive consumers after the prerequisites and before creating the Agent. The context owns the loop and every Agent returned by the harness.
 
 ```ts
 import { Context } from '@deepseek-ai/cordis'
-import AgentLoop from '@deepseek-ai/dsh-agent-loop'
-import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
+import {
+  mountAgentLoopTestDependencies,
+  mountAgentLoopTestHarness,
+} from '@deepseek-ai/dsh-agent-loop-testkit'
 
 const ctx = new Context()
 
 await mountAgentLoopTestDependencies(ctx)
-// Register the test adapter and any optional plugins here.
-await ctx.plugin(AgentLoop, { agents: [] })
+// Register the test adapter and any load-order-sensitive plugins here.
+const harness = await mountAgentLoopTestHarness(ctx)
+const agent = harness.create(SessionId('test-agent'))
+declare const message: UserMessage
+
+agent.inbox.append('next-turn', message)
+const admitted = harness.claim(agent, 'next-turn', 1)
 ```
 
-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.
+The dependency helper forwards system-prompt and tool-registry configuration through `options` and provides no test defaults beyond those services' own defaults. A plugin-load failure rejects the helper call; services activated earlier in the sequence remain context-owned and unwind when the context is disposed.
 
-### Build structural Agent stubs
+### Build a structural Agent stub
 
-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.
+Use `createInboxStub()` when the test subject needs mutable pending lists but does not exercise durability, projection validation, live Inbox notifications, or the driver's claim policy. The stub implements the public queue operations with two process-local arrays and never writes to a Session. Use `unsupportedInbox()` when the test subject must not touch pending input; every mutation throws at the first unexpected dependency.
 
 ```ts
-import { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxStub } 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: fixture.inbox,
+  inbox: createInboxStub(),
 }
 ```
 
 ### When to use it
 
-Use the mounting helper for tests whose subject is the loop: load order, retries, tool execution, or session behavior on a real prerequisite stack. Mount dependencies directly when a test probes service load order, injection failures, partial topologies, or teardown — the helper hides exactly the wiring such tests must control.
+Use the dependency and loop helpers for tests whose subject is production loop or durable Inbox behavior. Use the structural stub for consumer-domain tests that only need queue editing. Mount dependencies directly when a test probes service injection failures or partial topologies, because the helper hides exactly the wiring those tests must control.
 
 ### What can go wrong
 
-A plugin-load failure rejects the mounting helper call; services activated earlier in the sequence remain owned by your context and unwind with it. The context owns every mounted service, so dispose it after the test.
+The harness mounts no LLM adapter. Register an adapter before sending work that would start a model request. Dispose the owning context after every test so Agents reach quiescence and their scoped registrations unwind.
 
 -----
 
@@ -80,7 +86,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) 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.
+`mountAgentLoopTestDependencies` mounts six service plugins in a fixed dependency order — LLM, session, session-projection registry, system-prompt registry, tool registry, then agent registry — and stops before `AgentLoop`, so the caller controls loop load order. `mountAgentLoopTestHarness` mounts the public production plugin, creates Agents through its service, and exposes the production driver's claim operation without exporting the loop's concrete Inbox class or projection definition. [`src/inbox.ts`](src/inbox.ts) contains only the process-local mutable stub and the fail-fast unsupported placeholder; it owns no projection or durable event implementation. The mounting and driver implementation lives in [`src/index.ts`](src/index.ts). No invariant companion is published because the package owns only test helpers and has no independent production observations that can diverge.
 
 </details>
 
@@ -89,11 +95,11 @@ This section explains the design of the test utilities; the observable behavior
 <a id="further-exploration"></a>
 ## Further Exploration
 
-Read these pages when the package-level contract is not enough. They move from the loop to the services the helper mounts and the tests that use it.
+Read these pages when the package-level behavior is not enough. They move from the loop to the services the helper mounts and the tests that use it.
 
-- [Agent loop package](../../core/agent-loop/README.md) — the concrete loop this helper prepares tests for.
-- [Session package](../../core/session/README.md) — the session store the helper mounts.
-- [LLM package](../../llm/llm/README.md) — the LLM runtime and adapter contract the helper mounts.
+- [Agent loop package](../../core/agent-loop/README.md) — the concrete loop this helper mounts for production behavior.
+- [Session package](../../core/session/README.md) — the durable event log used by production Inbox behavior.
+- [LLM package](../../llm/llm/README.md) — the LLM runtime and adapter interface the helper prepares.
 - [Testing policy](../../../docs/testing.md) — the coverage tiers these tests serve.
 - [Test-support group map](../README.md) — sibling harnesses and support packages.
 
@@ -102,23 +108,22 @@ Read these pages when the package-level contract is not enough. They move from t
 <a id="model-experience"></a>
 ## Model Experience
 
-None, as these test-only utilities neither drive nor modify model requests.
+None, as these test-only utilities neither assemble nor modify model requests.
 
 #### KV Cache effect
 
-None; this package neither assembles nor sends a provider request.
+None; the package itself sends no provider request.
 
 ## Known Limitations and Deferred Work
 
 <a id="known-limitations-and-deferred-work"></a>
 
-
 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 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.
+- **Only the mandatory prerequisite spine is shared** — adapters, optional plugins, scenario-specific load order, and context teardown remain caller-owned.
+- **The production harness has no adapter default** — tests that start the loop must register the route they exercise.
+- **The mutable Inbox stub is process-local only** — use a harness-created Agent whenever durable events, projection recovery or validation, live notifications, or claim policy matter.
+- **The unsupported Inbox accepts no mutations** — use the mutable stub or a harness-created Agent whenever pending input is part of the test subject.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 35 - 30
packages/test-support/agent-loop-testkit/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "为 agent-loop 测试提供先决依赖挂载、基于会话的结构化 Inbox fixture 和快速失败的 Inbox 桩。"
+description: "为 agent-loop 测试提供先决依赖挂载、生产 AgentLoop 驱动与职责明确的 Inbox 桩。"
 kind: "package-library"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-library"
 
 ## 概述
 
-`dsh-agent-loop-testkit` 为测试在加载具体 `AgentLoop` 之前所需的全部标准先决服务——LLM(大语言模型)运行时、会话存储、会话投影注册表、系统提示词注册表、工具注册表与 agent(智能体)注册表——按依赖顺序一键挂载。loop 本身、适配器、可选插件、agent 与清理仍由测试掌控,因此每个场景都保持自己的加载顺序与拓扑。它还为消费方测试提供基于会话的结构化 Inbox fixture,并为不测试待处理输入的桩提供一个快速失败且不支持操作的 Inbox 占位值。当测试对象是 loop 行为而非服务接线时使用本包;针对注入失败或部分拓扑的测试会直接挂载其依赖。它自身不注册任何模型可见行为。
+`dsh-agent-loop-testkit` 为测试在加载具体 `AgentLoop` 之前所需的标准先决服务——LLM(大语言模型)运行时、会话存储、会话投影注册表、系统提示词注册表、工具注册表与 agent(智能体)注册表——按依赖顺序一键挂载。另一个辅助函数会挂载生产 loop,并返回一个精简驱动,用于创建真实 Agent 和通过真实 Inbox 认领输入。只需要公开队列操作的消费方测试可以改用明确标记为进程内实现的 Inbox 桩;不涉及待处理输入的测试则可以使用快速失败且不支持操作的 Inbox。适配器、可选插件、加载顺序与清理由测试掌控。本包自身不注册任何模型可见行为。
 
 ## 目录
 
@@ -25,48 +25,54 @@ kind: "package-library"
 <a id="use-this-package"></a>
 ## 使用本包
 
-本包在 loop 挂载前为 AgentLoop 测试提供可用的服务拓扑。
+本包为 AgentLoop 测试提供可用的服务拓扑,并要求测试明确选择生产 Inbox 行为或结构化桩
 
-### 最小示例
+### 驱动生产 Agent
+
+当测试覆盖持久 Inbox 事件、投影恢复或校验、实时 Inbox 通知,或 loop 驱动的认领策略时,使用 `mountAgentLoopTestHarness()`。应在挂载先决依赖后、创建 Agent 前挂载所有对加载顺序敏感的消费方。上下文拥有 loop 以及该 harness 返回的每个 Agent。
 
 ```ts
 import { Context } from '@deepseek-ai/cordis'
-import AgentLoop from '@deepseek-ai/dsh-agent-loop'
-import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
+import {
+  mountAgentLoopTestDependencies,
+  mountAgentLoopTestHarness,
+} from '@deepseek-ai/dsh-agent-loop-testkit'
 
 const ctx = new Context()
 
 await mountAgentLoopTestDependencies(ctx)
-// Register the test adapter and any optional plugins here.
-await ctx.plugin(AgentLoop, { agents: [] })
+// Register the test adapter and any load-order-sensitive plugins here.
+const harness = await mountAgentLoopTestHarness(ctx)
+const agent = harness.create(SessionId('test-agent'))
+declare const message: UserMessage
+
+agent.inbox.append('next-turn', message)
+const admitted = harness.claim(agent, 'next-turn', 1)
 ```
 
-挂载辅助函数按依赖顺序激活 LLM、会话、会话投影、系统提示词、工具与 agent 服务,并在 loop 挂载前返回。系统提示词与工具注册表配置可通过 `options` 转发;除服务自有的默认值外,本辅助函数不提供测试默认值。
+依赖辅助函数通过 `options` 转发系统提示词与工具注册表配置,除这些服务自有的默认值外不提供测试默认值。插件加载失败会使辅助函数调用被拒绝;顺序中较早激活的服务仍归上下文所有,并在上下文释放时一并解除
 
 ### 构造结构化 Agent 桩
 
-当待处理输入属于测试对象时,使用 `createInboxFixture(ctx.sessionProjections, session)`。它会返回供 Agent 对象字面量使用的 `inbox`,以及供测试驱动使用的独立 `claim` 操作。应先创建 fixture,再构造 Agent 对象字面量,使对象从构造开始就满足必需的结构化接口。仅当测试对象不涉及待处理的 Agent 输入时才使用 `unsupportedInbox()`;它公开空的待处理列表,并在每次变更时抛错,因此意外的 Inbox 依赖会在首次写入时失败
+当测试对象需要可变的待处理列表,但不测试持久性、投影校验、实时 Inbox 通知或驱动的认领策略时,使用 `createInboxStub()`。该桩通过两个进程内数组实现公开队列操作,且绝不会写入 Session。当测试对象不应访问待处理输入时,使用 `unsupportedInbox()`;每次变更都会在首个意外依赖处抛错
 
 ```ts
-import { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxStub } 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: fixture.inbox,
+  inbox: createInboxStub(),
 }
 ```
 
 ### 何时使用
 
-当测试对象是 loop 本身——在真实先决依赖栈上的加载顺序、重试、工具执行或会话行为——时使用挂载辅助函数。当测试要探测服务加载顺序、注入失败、部分拓扑或清理时,请直接挂载依赖——辅助函数隐藏的正是这类测试必须控制的接线。
+当测试对象是生产 loop 或持久 Inbox 行为时,使用依赖与 loop 辅助函数。只需要编辑队列的消费方领域测试使用结构化桩。当测试探测服务注入失败或部分拓扑时,请直接挂载依赖,因为辅助函数隐藏的正是这类测试必须控制的接线。
 
 ### 可能出什么问题
 
-插件加载失败会使挂载辅助函数调用被拒绝;顺序中较早激活的服务仍归你的上下文所有,并随上下文一起解除。上下文拥有所有已挂载服务,因此测试结束后请 dispose(资源释放)它
+harness 不会挂载任何 LLM 适配器。若测试发送的任务会启动模型请求,请先注册被测路由的适配器。每个测试结束后都应释放所属上下文,使 Agent 达到静止状态并解除其作用域注册
 
 -----
 
@@ -80,7 +86,7 @@ const agent = {
 
 ### 设计
 
-`mountAgentLoopTestDependencies` 按固定依赖顺序——LLM、会话、会话投影注册表、系统提示词注册表、工具注册表、agent 注册表——挂载六个服务插件,并刻意在 `AgentLoop` 之前停下,使调用方控制 loop 加载顺序与待测拓扑。[`src/inbox.ts`](src/inbox.ts) 持有针对公开持久 Inbox 事件与状态约定的测试专用投影定义、结构化命令 facade、驱动方 claim 操作,以及快速失败且不支持操作的占位值。它不会导入包内部的 loop 实现。挂载实现位于 [`src/index.ts`](src/index.ts)。本测试支持包不持有任何生产事件流或可变数据,因此不发布伴生入口;消费它的测试套件会直接检验其行为
+`mountAgentLoopTestDependencies` 按固定依赖顺序——LLM、会话、会话投影注册表、系统提示词注册表、工具注册表、agent 注册表——挂载六个服务插件,并在 `AgentLoop` 之前停下,使调用方控制 loop 加载顺序。`mountAgentLoopTestHarness` 挂载公开的生产插件,通过其服务创建 Agent,并公开生产驱动的认领操作,而不导出 loop 的具体 Inbox 类或投影定义。[`src/inbox.ts`](src/inbox.ts) 仅包含进程内可变桩和快速失败且不支持操作的占位值;它不持有投影或持久事件实现。挂载与驱动实现位于 [`src/index.ts`](src/index.ts)。本包不发布 invariant companion,因为它只持有测试辅助工具,不存在可能相互偏离的独立生产观测
 
 </details>
 
@@ -89,11 +95,11 @@ const agent = {
 <a id="further-exploration"></a>
 ## 进一步探索
 
-当包级约定不够用时阅读以下页面。它们从 loop 逐步进入辅助函数挂载的服务以及使用它的测试。
+当包级行为不够用时阅读以下页面。它们从 loop 逐步进入辅助函数挂载的服务以及使用它的测试。
 
-- [Agent loop 包](../../core/agent-loop/README.zh.md)——本辅助函数为之准备测试的具体 loop。
-- [会话包](../../core/session/README.zh.md)——辅助函数挂载的会话存储
-- [LLM 包](../../llm/llm/README.zh.md)——辅助函数挂载的 LLM 运行时与适配器约定
+- [Agent loop 包](../../core/agent-loop/README.zh.md)——本辅助函数为生产行为挂载的具体 loop。
+- [会话包](../../core/session/README.zh.md)——生产 Inbox 行为使用的持久事件日志
+- [LLM 包](../../llm/llm/README.zh.md)——本辅助函数准备的 LLM 运行时与适配器接口
 - [测试策略](../../../docs/testing.zh.md)——这些测试所服务的覆盖层级。
 - [test-support 组地图](../README.zh.md)——兄弟 harness 与支持包。
 
@@ -102,23 +108,22 @@ const agent = {
 <a id="model-experience"></a>
 ## 模型体验
 
-无。这些测试专用辅助工具既不驱动也不修改模型请求。
+无。这些测试专用辅助工具既不组装也不修改模型请求。
 
 #### KV Cache 影响
 
-无;本包既不组装也不发送提供方请求。
+无;本包自身不发送提供方请求。
 
 ## 已知限制与延期工作
 
 <a id="known-limitations-and-deferred-work"></a>
 
-
 这些限制说明辅助工具不共享什么。它们是当前包约束,不是任务积压。
 
-- **只共享必需的先决主干**——适配器、可选插件、`AgentLoop`、agent 与上下文清理仍由调用方负责,以使特定场景的挂载顺序清晰可见
-- **结构化 fixture 只发出持久会话事件**——它不会复现由 loop 实现持有的实时 `agent/inbox/inserted`、`agent/inbox/claimed` 或 `agent/inbox/discarded` 通知
-- **结构化 fixture 接受受信的测试事件**——它不会重复生产 provider 的持久 splice 校验;无效历史覆盖由聚焦的 `agent-loop` 测试持有
-- **不支持操作的 Inbox 不接受变更**——只要待处理输入属于测试对象,就应使用 `createInboxFixture()`
+- **只共享必需的先决主干**——适配器、可选插件、场景特定的加载顺序与上下文清理仍由调用方负责
+- **生产 harness 没有适配器默认值**——启动 loop 的测试必须注册其实际使用的路由
+- **可变 Inbox 桩仅存在于进程内**——只要持久事件、投影恢复或校验、实时通知或认领策略属于测试对象,就应使用 harness 创建的 Agent
+- **不支持操作的 Inbox 不接受变更**——只要待处理输入属于测试对象,就应使用可变桩或 harness 创建的 Agent
 
 <a id="dev-note"></a>
 ### 开发备注

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

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-agent-loop-testkit",
-  "description": "Prerequisite mounting and session-backed Inbox fixtures for agent-loop tests",
+  "description": "Prerequisite mounting, production AgentLoop drivers, and Inbox stubs for tests",
   "version": "0.1.2-alpha.3",
   "publishConfig": {
     "access": "public"
@@ -28,6 +28,7 @@
   "license": "MIT",
   "peerDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-agent-loop": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
@@ -35,9 +36,7 @@
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   },
-  "dependencies": {
-    "zod": "^4.4.3"
-  },
+  "dependencies": {},
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent-loop": "workspace:^",

+ 24 - 102
packages/test-support/agent-loop-testkit/src/inbox.ts

@@ -1,132 +1,54 @@
-import type { Inbox, InboxState, InboxTarget, InboxWireState } from '@deepseek-ai/dsh-agent'
+import type { Inbox, InboxTarget } 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[]
-}
+import type { UserMessage } from '@deepseek-ai/dsh-session'
 
 /**
- * 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.
+ * Create a mutable in-memory Inbox stub for tests that exercise only the public
+ * queue operations. Durable events, projection validation, and live Inbox
+ * notifications require a real Agent created by the AgentLoop test harness.
+ * @returns an Inbox backed by two process-local arrays.
  */
-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
+export function createInboxStub(): Inbox {
+  const pending: Record<InboxTarget, UserMessage[]> = {
+    'next-turn': [],
+    'next-step': [],
   }
 
   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' } : {}),
+    for (const target of ['next-turn', 'next-step'] as const) {
+      const index = pending[target].findIndex(message => message.id === messageId)
+      if (index >= 0) return { target, index }
     }
-    const removed = pending.slice(index, index + count)
-    session.append('agent/inbox/spliced', event)
-    return removed
+    return undefined
   }
 
-  const inbox: Inbox = {
-    get nextTurn() { return current()['next-turn'] },
-    get nextStep() { return current()['next-step'] },
+  return {
+    get nextTurn() { return pending['next-turn'] },
+    get nextStep() { return pending['next-step'] },
     clear() {
-      mutate('next-step', 0, current()['next-step'].length, [], true)
-      mutate('next-turn', 0, current()['next-turn'].length, [], true)
+      pending['next-step'].splice(0)
+      pending['next-turn'].splice(0)
     },
     append(target, message) {
-      mutate(target, current()[target].length, 0, [message], true)
+      pending[target].push(message)
     },
     prepend(target, message) {
-      mutate(target, 0, 0, [message], true)
+      pending[target].unshift(message)
     },
     replace(messageId, message) {
       const location = locate(messageId)
       if (location === undefined) return false
-      mutate(location.target, location.index, 1, [message], true)
+      pending[location.target].splice(location.index, 1, message)
       return true
     },
     remove(messageId) {
       const location = locate(messageId)
       if (location === undefined) return false
-      mutate(location.target, location.index, 1, [], true)
+      pending[location.target].splice(location.index, 1)
       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
+      return pending[target].splice(start, deleteCount, ...inserted)
     },
   }
 }

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

@@ -1,25 +1,48 @@
 /**
- * Shared service mounting and session-backed Inbox fixtures for agent-loop
- * tests. Callers retain ownership of their contexts, loops, adapters,
- * optional plugins, and teardown.
+ * Shared service mounting, real AgentLoop drivers, and structural Inbox stubs
+ * for agent-loop tests. Callers retain ownership of their contexts, adapters,
+ * optional plugins, agents, and teardown.
  * @module @deepseek-ai/dsh-agent-loop-testkit
  */
 
 import type { Context } from '@deepseek-ai/cordis'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent, AgentOptions, Inbox, InboxTarget } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import LlmRuntime from '@deepseek-ai/dsh-llm'
 import SessionStore from '@deepseek-ai/dsh-session'
+import type { SessionHeader, SessionId, UserMessage } 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'
 import type { Config as ToolRuntimeConfig } from '@deepseek-ai/dsh-tools'
 
-export {
-  createInboxFixture,
-  unsupportedInbox,
-  type InboxFixture,
-} from './inbox.ts'
+export { createInboxStub, unsupportedInbox } from './inbox.ts'
+
+interface DriverInbox extends Inbox {
+  claim(target: InboxTarget, turn: number): UserMessage[]
+}
+
+/** Test driver for production Agents created by a mounted AgentLoop. */
+export interface AgentLoopTestHarness {
+  /**
+   * Create a production Agent and fresh Session owned by the harness context.
+   * @param id - shared Agent and Session identity.
+   * @param options - concrete loop options.
+   * @param meta - optional fresh-session workspace metadata.
+   * @returns the published production Agent.
+   */
+  create(id: SessionId, options?: AgentOptions, meta?: Pick<SessionHeader, 'cwd'>): Agent
+  /**
+   * Admit pending messages through the production loop driver's claim operation.
+   * @param agent - Agent returned by this harness's `create` method.
+   * @param target - boundary whose pending input is admitted.
+   * @param turn - turn that owns the admitted messages.
+   * @returns next-step messages followed by one next-turn message when requested.
+   */
+  claim(agent: Agent, target: InboxTarget, turn: number): UserMessage[]
+}
 
 /** Configuration forwarded to the prerequisite service plugins. */
 export interface AgentLoopTestDependenciesOptions {
@@ -52,3 +75,19 @@ export async function mountAgentLoopTestDependencies(
   await ctx.plugin(ToolRuntime, options.tools ?? {})
   await ctx.plugin(AgentRegistry)
 }
+
+/**
+ * Mount the production AgentLoop and expose its narrow test-driver operations.
+ * Mount {@link mountAgentLoopTestDependencies} and any load-order-sensitive
+ * consumers before calling this helper. The context owns the loop and every
+ * Agent returned by the harness.
+ * @param ctx - test context with the AgentLoop prerequisite services active.
+ * @returns a driver that creates production Agents and claims their real Inbox.
+ */
+export async function mountAgentLoopTestHarness(ctx: Context): Promise<AgentLoopTestHarness> {
+  await ctx.plugin(AgentLoop, { agents: [] })
+  return {
+    create: (id, options = {}, meta = {}) => ctx.agentLoop.create(id, options, meta),
+    claim: (agent, target, turn) => (agent.inbox as DriverInbox).claim(target, turn),
+  }
+}

+ 60 - 35
packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts

@@ -1,13 +1,12 @@
 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 {
-  createInboxFixture,
+  createInboxStub,
   mountAgentLoopTestDependencies,
+  mountAgentLoopTestHarness,
   unsupportedInbox,
 } from '../src/index.ts'
 
@@ -24,7 +23,7 @@ describe('dsh-agent-loop-testkit', () => {
     expect(() => { inbox.clear() }).toThrow('this test Agent does not support Inbox mutations')
   })
 
-  it('mounts a configurable prerequisite spine that can activate AgentLoop', async () => {
+  it('mounts a configurable prerequisite spine and the production AgentLoop', async () => {
     const ctx = new Context()
     await mountAgentLoopTestDependencies(ctx, {
       systemPrompt: { persona: 'Test persona.' },
@@ -32,51 +31,77 @@ describe('dsh-agent-loop-testkit', () => {
     })
 
     expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain('Test persona.')
-    await expect(ctx.plugin(AgentLoop, { agents: [] })).resolves.toBeDefined()
+    await expect(mountAgentLoopTestHarness(ctx)).resolves.toBeDefined()
 
     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)
+  it('provides a mutable in-memory Inbox stub for structural Agent tests', () => {
+    const inbox = createInboxStub()
     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])
+    inbox.append('next-turn', firstTurn)
+    inbox.prepend('next-turn', secondTurn)
+    inbox.append('next-step', firstStep)
+    expect(inbox.nextTurn).toEqual([secondTurn, firstTurn])
+    expect(inbox.nextStep).toEqual([firstStep])
+
+    expect(inbox.replace(firstTurn.id, editedTurn)).toBe(true)
+    expect(inbox.replace(firstStep.id, editedStep)).toBe(true)
+    expect(inbox.replace(firstTurn.id, message('missing replacement'))).toBe(false)
+    expect(inbox.remove(firstTurn.id)).toBe(false)
+    expect(inbox.splice('next-turn', -1, 1, [])).toEqual([editedTurn])
+    expect(inbox.remove(editedStep.id)).toBe(true)
 
-    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)
+    inbox.clear()
+    expect(inbox.nextTurn).toEqual([])
+    expect(inbox.nextStep).toEqual([])
+  })
 
-    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])
+  it('drives durable Inbox behavior through a production Agent', async () => {
+    const ctx = new Context()
+    await mountAgentLoopTestDependencies(ctx)
+    const harness = await mountAgentLoopTestHarness(ctx)
+    const agent = harness.create(SessionId('agent-loop-testkit-inbox'))
+    const turn = message('turn')
+    const step = message('step')
+    const inserted: string[] = []
+    const claimed: Array<{ id: string; turn: number }> = []
+    ctx.on('agent/inbox/inserted', ({ agent: subject, message: pending }) => {
+      if (subject === agent) inserted.push(pending.id)
+    })
+    ctx.on('agent/inbox/claimed', ({ agent: subject, message: pending, turn: ownerTurn }) => {
+      if (subject === agent) claimed.push({ id: pending.id, turn: ownerTurn })
+    })
 
-    const eventCount = session.snapshotEvents().length
-    expect(fixture.inbox.splice('next-step', 100, -1, [])).toEqual([])
-    expect(session.snapshotEvents()).toHaveLength(eventCount)
+    agent.inbox.append('next-turn', turn)
+    agent.inbox.append('next-step', step)
 
-    fixture.inbox.clear()
-    expect(fixture.inbox.nextTurn).toEqual([])
-    expect(fixture.inbox.nextStep).toEqual([])
-    fixture.inbox.clear()
+    expect(inserted).toEqual([turn.id, step.id])
+    expect(() => { agent.inbox.append('next-step', turn) }).toThrow(`message "${turn.id}" is already pending`)
+    const invalid = Session.create(SessionId('invalid-persisted-inbox'), [{
+      type: 'agent/inbox/spliced',
+      seq: 0,
+      time: 1,
+      data: { target: 'next-turn', start: 99, inserted: [] },
+    }])
+    expect(() => ctx.sessionProjections.stateOf(invalid, 'inbox'))
+      .toThrow(/invalid persisted inbox splice/)
+    expect(harness.claim(agent, 'next-turn', 3)).toEqual([step, turn])
+    expect(claimed).toEqual([
+      { id: step.id, turn: 3 },
+      { id: turn.id, turn: 3 },
+    ])
+    expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([
+      'agent/inbox/spliced',
+      'agent/inbox/spliced',
+      'agent/inbox/spliced',
+      'agent/inbox/spliced',
+    ])
 
     await ctx.fiber.dispose()
   })

+ 0 - 4
pnpm-lock.yaml

@@ -8919,10 +8919,6 @@ 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:^