diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 90c1f5a898..3989c90eb9 100644 --- a/docs/config-catalog.i18n.yaml +++ b/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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 12e50800ac..85aead7a93 100644 --- a/docs/config-catalog.md +++ b/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) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index bb39c05f51..f91ebc6c46 100644 --- a/docs/config-catalog.zh.md +++ b/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) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 5fff379418..894b0155c0 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 676d7e5db4..a47c1df5ac 100644 --- a/docs/event-producer-consumer.md +++ b/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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index c42529a9f3..5baa32146e 100644 --- a/docs/event-producer-consumer.zh.md +++ b/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) | diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index f8b2d850d5..4bc7f068a7 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/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', { diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts index 04acff8465..b51d364044 100644 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ b/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 @@ -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)) diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index 226ed9fdb7..4a2eeeac20 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/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: { diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index f7d952623d..e0e0907edd 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/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, } } diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 84ed8466cb..46fb2617b7 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/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) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index a43d598c13..937e8b37df 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/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() + +/** 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 { 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 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 } diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 2262efcd82..571232e93b 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/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 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 4466fb4ff0..e6fb94a512 100644 --- a/packages/core/agent-loop/README.md +++ b/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 diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 50259f1fa5..8048691399 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/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` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。 ### 失败与取消 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 88c1e48dcd..20beb22ad6 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/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 = new Set([ FiberState.UNLOADING, diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 82db3b4fb5..7132d27d72 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/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 } } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 4387aa0bbc..e33be74014 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/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, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 970a809ef3..a94143a563 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/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) { diff --git a/packages/test-support/agent-loop-testkit/README.i18n.yaml b/packages/test-support/agent-loop-testkit/README.i18n.yaml index c42ef60861..21902118b0 100644 --- a/packages/test-support/agent-loop-testkit/README.i18n.yaml +++ b/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 diff --git a/packages/test-support/agent-loop-testkit/README.md b/packages/test-support/agent-loop-testkit/README.md index 8aaa982f78..1bc4e31ac6 100644 --- a/packages/test-support/agent-loop-testkit/README.md +++ b/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[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. @@ -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. ### Dev Note diff --git a/packages/test-support/agent-loop-testkit/README.zh.md b/packages/test-support/agent-loop-testkit/README.zh.md index ded8c9a4f6..41345f9ab3 100644 --- a/packages/test-support/agent-loop-testkit/README.zh.md +++ b/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[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)。本测试支持包不持有任何生产事件流或可变数据,因此不发布伴生入口;消费它的测试套件会直接检验其行为。 @@ -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()`。 ### 开发备注 diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index 34c34c9dad..89a5cec445 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/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:^", diff --git a/packages/test-support/agent-loop-testkit/src/inbox.ts b/packages/test-support/agent-loop-testkit/src/inbox.ts index 2535fbd76a..6a50d1b5df 100644 --- a/packages/test-support/agent-loop-testkit/src/inbox.ts +++ b/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()).readonly(), + 'next-step': z.array(z.custom()).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, + 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. diff --git a/packages/test-support/agent-loop-testkit/src/index.ts b/packages/test-support/agent-loop-testkit/src/index.ts index 9de89c7583..cde26ba7ee 100644 --- a/packages/test-support/agent-loop-testkit/src/index.ts +++ b/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 { diff --git a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts index 1ac958e3af..b13ab29717 100644 --- a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts +++ b/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() + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2202aa8730..7f1d7f2ea9 100644 --- a/pnpm-lock.yaml +++ b/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:^