2026-07-13 11:58:55 +08:00
|
|
|
import { describe, expect, expectTypeOf, it } from 'vitest'
|
2026-07-12 08:57:05 +08:00
|
|
|
import { Context, Service, symbols } from 'cordis'
|
2026-07-13 11:58:55 +08:00
|
|
|
import type { Events } from 'cordis'
|
2026-06-11 15:17:56 +08:00
|
|
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
2026-07-20 21:38:49 +08:00
|
|
|
import AgentRegistry, {
|
2026-07-23 20:45:29 +08:00
|
|
|
AgentMessageId,
|
2026-07-20 21:38:49 +08:00
|
|
|
agentEvents,
|
|
|
|
|
agentInterruptReasonOf,
|
|
|
|
|
} from '@deepseek-ai/dsh-agent'
|
2026-07-14 01:59:21 +08:00
|
|
|
|
2026-07-24 12:27:20 +08:00
|
|
|
import type {
|
|
|
|
|
Agent,
|
|
|
|
|
AgentCancelCause,
|
|
|
|
|
AgentFactory,
|
|
|
|
|
ContinuationStop,
|
|
|
|
|
CreateAgentOptions,
|
|
|
|
|
InjectOptions,
|
2026-07-24 13:52:25 +08:00
|
|
|
ResolvedAgentInput,
|
2026-07-24 12:27:20 +08:00
|
|
|
ResumeAgentOptions,
|
|
|
|
|
SendOptions,
|
|
|
|
|
} from '@deepseek-ai/dsh-agent'
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
2026-07-23 19:15:45 +08:00
|
|
|
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
2026-07-14 01:59:21 +08:00
|
|
|
const id = SessionId(rawId)
|
2026-07-24 12:27:20 +08:00
|
|
|
return {
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
id,
|
|
|
|
|
options: {},
|
2026-07-14 07:39:28 +08:00
|
|
|
session: new Session(id),
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
status: 'idle',
|
feat(agent): the agent is a registration scope — Agent.ctx, setup slot, fused scoped dispatch
Every live agent owns a dsh-scope context (Agent.ctx, key = the agent),
minted inside the loop's composite lifecycle effect: registrations through
it are agent-visible and agent-lifetime, and agent.ctx listeners hear only
that agent's dispatches. The composite yields the scope's raw disposer
first (identity-nested, no un-nested window), then session entry (scoped
enter captures the session carrier), then registration; teardown runs
stop/drain -> unregister -> detach session -> unwind scope, keeping
store/registry rollback synchronous on every failure path.
CreateAgentOptions.setup(agentCtx) runs after the scope is minted and the
agent registered, before agent/session-start and the loop start — the slot
where a creator composes the agent's scoped world (persona sections,
restrict(), scoped tools); a throwing setup unwinds inside the rollback
boundary. Setup registers, it never drives.
agentEvents(ctx, agent) fuses the scope carrier with the injected subject
argument for every agent/* dispatch (the correct dispatch is the shortest
spelling); assembleContextFor(agent) pairs the agent DX field with the
scope layer selector. All loop/agent/registry dispatch sites converted;
agent/* event declarations carry this: Scoped<Agent>; ctx.agent is a safe
root accessor defaulting undefined, shadowed by each agent context.
2026-07-09 01:17:47 +08:00
|
|
|
ctx: new Context(),
|
2026-07-24 15:08:36 +08:00
|
|
|
followup: () => AgentMessageId('stub'),
|
2026-07-24 12:27:20 +08:00
|
|
|
queue: () => AgentMessageId('stub'),
|
|
|
|
|
steer: () => AgentMessageId('stub'),
|
|
|
|
|
inject: () => AgentMessageId('stub'),
|
2026-07-24 15:08:36 +08:00
|
|
|
send: () => AgentMessageId('stub'),
|
feat(agent): add queue-aware Agent.cancel() primitive
abort() only kills the in-flight step, so a queued-but-not-yet-started prompt
ran to completion after a cancel and a prompt accepted right after could be
batched into the cancelled turn (the loop merges queued messages into one turn).
This closes TODO(rfc010-cancel-prestep) with a distinct cancel() verb.
cancel() clears the queued + steering FIFOs, aborts the in-flight step, and
drives a turn-scoped marker on the LoopHandle that the driver checks at EVERY
point a turn could start or continue:
- right after the idle wait (window 1): drop the about-to-run turn and settle
whenIdle() waiters directly (no running→idle transition fires, and no
agent/status is emitted, so an ACP listener can't see a spurious idle that
resolves a freshly-queued prompt as cancelled);
- after the synchronous setStatus('running') emit (window 2): a running listener
can cancel in the gap before runTurn;
- in the step-start window (before runStep, after setAbort): a synchronous
turn-start/step-start listener can cancel before any AbortController exists;
- at the continuation gate: a cancel during the continuation waterfall (the
finished step's controller already cleared) ends the turn aborted.
The marker is ARMED only when there is something to cancel (running, an
in-flight step, or queued/steering work) — an idle no-op cancel cannot leave it
set to drop a later prompt — and RESET unconditionally once per loop iteration,
so it governs exactly one turn and never leaks onto the next prompt (even when a
send() lands in the cancelled turn's flush window).
ACP session/cancel now maps to agent.cancel() (keeping the synchronous
settlePrompt). Teardown/disconnect still use abort('disposed') until PR D, so
the ACP README narrows the remaining best-effort window to teardown only.
Tests (agent-loop/cancel.spec.ts) cover every window unit-level (the F1 hang
guard: a whenIdle() waiter registered before a pre-step cancel resolves; the F2
leak guard: idle cancel then a prompt runs; mid-step, continuation, both
pre-step windows, turn-start-listener, steering-cleared, marker-reset). ACP
turns.spec.ts adds the through-bridge tests with NO intervening whenIdle (idle
cancel→prompt runs; mid-stream cancel→immediate next prompt runs) and updates
the stale pre-step test to the queue-aware guarantee. The existing cancel
snapshot golden is byte-identical (it drives the new cancel() path end-to-end
through the real subprocess), so no new golden is needed. 100% coverage.
2026-06-20 04:51:32 +08:00
|
|
|
cancel() {},
|
2026-06-16 11:10:25 +08:00
|
|
|
whenIdle() { return Promise.resolve() },
|
2026-07-23 19:15:45 +08:00
|
|
|
...overrides,
|
2026-07-24 12:27:20 +08:00
|
|
|
}
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('AgentRegistry', () => {
|
2026-07-24 13:52:25 +08:00
|
|
|
it('keeps helper options semantic and makes advanced input fully specified', () => {
|
|
|
|
|
type OptionalInputKey = {
|
|
|
|
|
[Key in keyof ResolvedAgentInput]-?: Record<never, never> extends Pick<ResolvedAgentInput, Key>
|
|
|
|
|
? Key
|
|
|
|
|
: never
|
|
|
|
|
}[keyof ResolvedAgentInput]
|
|
|
|
|
|
2026-07-24 12:27:20 +08:00
|
|
|
expectTypeOf<'target' extends keyof SendOptions ? true : false>().toEqualTypeOf<false>()
|
|
|
|
|
expectTypeOf<'wakeup' extends keyof SendOptions ? true : false>().toEqualTypeOf<false>()
|
|
|
|
|
expectTypeOf<'contexts' extends keyof InjectOptions ? true : false>().toEqualTypeOf<false>()
|
2026-07-24 15:08:36 +08:00
|
|
|
expectTypeOf<Parameters<Agent['send']>[0]>().toEqualTypeOf<ResolvedAgentInput>()
|
2026-07-24 13:52:25 +08:00
|
|
|
expectTypeOf<OptionalInputKey>().toEqualTypeOf<never>()
|
|
|
|
|
expectTypeOf<Extract<ResolvedAgentInput, { target: 'next-step'; wakeup: false }>['contexts']>()
|
|
|
|
|
.toEqualTypeOf<[]>()
|
2026-07-24 12:27:20 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-16 18:12:34 +08:00
|
|
|
it('allows terminal stop policy to cooperate asynchronously with turn cancellation', () => {
|
2026-07-13 11:58:55 +08:00
|
|
|
type TurnStopListener = Events['agent/turn-stop']
|
|
|
|
|
type AsyncTurnStopListener = () => Promise<ContinuationStop | undefined>
|
|
|
|
|
|
2026-07-16 18:12:34 +08:00
|
|
|
expectTypeOf<AsyncTurnStopListener>().toExtend<TurnStopListener>()
|
|
|
|
|
expectTypeOf<Awaited<ReturnType<TurnStopListener>>>().toEqualTypeOf<ContinuationStop | undefined>()
|
2026-07-13 11:58:55 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
2026-07-12 22:36:04 +08:00
|
|
|
const lifecycle: string[] = []
|
|
|
|
|
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
|
|
|
|
|
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
|
|
|
|
const agent = stubAgent('a1')
|
|
|
|
|
const dispose = ctx.agents.register(agent)
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(ctx.agents.get(agent.id)).toBe(agent)
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
expect(ctx.agents.list()).toEqual([agent])
|
2026-07-14 07:13:42 +08:00
|
|
|
expect(ctx.agents.roots()).toEqual([agent])
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/)
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
2026-07-13 11:58:55 +08:00
|
|
|
dispose()
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
|
|
|
|
expect(lifecycle).toEqual(['created:a1', 'disposed:a1'])
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-14 07:39:28 +08:00
|
|
|
it('rejects an agent whose registry and session identities differ', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
2026-07-23 19:15:45 +08:00
|
|
|
const agent = stubAgent('agent-id', { session: new Session(SessionId('session-id')) })
|
2026-07-14 07:39:28 +08:00
|
|
|
|
|
|
|
|
expect(() => ctx.agents.enter(agent, undefined))
|
|
|
|
|
.toThrow('agent id "agent-id" does not match session id "session-id"')
|
|
|
|
|
expect(ctx.agents.list()).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-14 07:13:42 +08:00
|
|
|
it('tracks runtime creator ownership separately from registry order', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
const root = stubAgent('root')
|
|
|
|
|
const child = stubAgent('child')
|
|
|
|
|
const detachRoot = ctx.agents.enter(root, undefined)
|
|
|
|
|
ctx.agents.announce(root)
|
|
|
|
|
const detachChild = ctx.agents.enter(child, root)
|
|
|
|
|
ctx.agents.announce(child)
|
|
|
|
|
|
|
|
|
|
expect(ctx.agents.list()).toEqual([root, child])
|
|
|
|
|
expect(ctx.agents.roots()).toEqual([root])
|
2026-07-14 09:46:05 +08:00
|
|
|
expect(ctx.agents.isOwnedBy(child.id, root)).toBe(true)
|
|
|
|
|
expect(ctx.agents.isOwnedBy(root.id, root)).toBe(false)
|
|
|
|
|
expect(ctx.agents.isOwnedBy(SessionId('missing'), root)).toBe(false)
|
2026-07-14 07:13:42 +08:00
|
|
|
|
|
|
|
|
detachChild()
|
2026-07-14 09:46:05 +08:00
|
|
|
expect(ctx.agents.isOwnedBy(child.id, root)).toBe(false)
|
2026-07-14 07:13:42 +08:00
|
|
|
detachRoot()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => {
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
2026-07-12 22:36:04 +08:00
|
|
|
const lifecycle: string[] = []
|
|
|
|
|
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
|
|
|
|
|
ctx.on('agent/created', () => { throw new Error('creation veto') })
|
|
|
|
|
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto')
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined()
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed'])
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
})
|
fix: make the six registration methods atomic under a throwing change-listener (P1-1)
llm.registerAdapter, agents.register, sessions.create, systemPrompt.section,
systemPrompt.tools, and tools.register each mutated state, emitted a change
event, then returned the disposer. In Cordis a synchronous throw before the
effect returns its disposer leaves nothing for the fiber to collect, so a
throwing change-listener leaked the registry entry permanently — HMR/dispose
could not clean it, and the duplicate-name/already-exists check stayed wedged
until restart.
Convert each to the generator-effect pattern already proven in
AgentLoop.create: mutate state, `yield` the disposer that undoes it (collected
before the next step runs, so it is torn down if a later step throws), THEN
emit the change event. The existing duplicate-name throws are unchanged — they
fire before any mutation, so they correctly leak nothing. No public API change:
generator effects are still synchronous SyncEffects and register() keeps
returning its fire-and-forget disposer wrapper.
Tests: a listener-throw rollback test for all six methods — register with a
change-listener that throws, assert the call throws AND the registry is clean
(entry absent; a subsequent listener-free register of the same name succeeds
and contributes exactly once). For systemPrompt (no duplicate-name check) the
two tests assert assembly is clean. Verified each fails against the pre-fix
emit-before-return-disposer form.
2026-06-15 00:25:17 +08:00
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
it('contains asynchronous creation rejection and every disposal-listener failure', async () => {
|
2026-07-12 05:13:17 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
const warnings: string[] = []
|
|
|
|
|
const heard: string[] = []
|
2026-07-12 22:36:04 +08:00
|
|
|
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
|
|
|
|
ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never)
|
|
|
|
|
ctx.on('agent/disposed', () => { throw new Error('disposed sync') })
|
|
|
|
|
ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never)
|
|
|
|
|
ctx.on('agent/disposed', agent => void heard.push(agent.id))
|
2026-07-12 05:13:17 +08:00
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
const dispose = ctx.agents.register(stubAgent('contained'))
|
2026-07-12 05:13:17 +08:00
|
|
|
await Promise.resolve()
|
2026-07-13 11:58:55 +08:00
|
|
|
dispose()
|
2026-07-12 05:13:17 +08:00
|
|
|
await Promise.resolve()
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(heard).toEqual(['contained'])
|
2026-07-12 05:13:17 +08:00
|
|
|
expect(warnings).toEqual([
|
2026-07-12 22:36:04 +08:00
|
|
|
'agent "contained": agent/created listener rejected: Error: created async',
|
|
|
|
|
'agent "contained": agent/disposed listener threw: Error: disposed sync',
|
|
|
|
|
'agent "contained": agent/disposed listener rejected: Error: disposed async',
|
2026-07-12 05:13:17 +08:00
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', async () => {
|
2026-07-11 22:55:26 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
2026-07-12 22:36:04 +08:00
|
|
|
const lifecycle: string[] = []
|
|
|
|
|
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
|
|
|
|
|
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
|
2026-07-11 22:55:26 +08:00
|
|
|
|
|
|
|
|
const first = stubAgent('split')
|
2026-07-14 07:13:42 +08:00
|
|
|
const detachFirst = ctx.agents.enter(first, undefined)
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(lifecycle).toEqual([])
|
2026-07-11 22:55:26 +08:00
|
|
|
ctx.agents.announce(first)
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/)
|
2026-07-11 22:55:26 +08:00
|
|
|
detachFirst()
|
|
|
|
|
detachFirst()
|
|
|
|
|
|
|
|
|
|
const replacement = stubAgent('split')
|
2026-07-14 07:13:42 +08:00
|
|
|
const detachReplacement = ctx.agents.enter(replacement, undefined)
|
2026-07-11 22:55:26 +08:00
|
|
|
detachFirst()
|
|
|
|
|
expect(ctx.agents.get(replacement.id)).toBe(replacement)
|
|
|
|
|
expect(() => { ctx.agents.announce(first) }).toThrow(/not live/)
|
|
|
|
|
detachReplacement()
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(lifecycle).toEqual(['created:split', 'disposed:split'])
|
2026-07-12 05:13:17 +08:00
|
|
|
})
|
2026-07-12 08:57:05 +08:00
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
it('defers detach requested by a creation listener until that dispatch unwinds', async () => {
|
2026-07-12 08:57:05 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
const order: string[] = []
|
2026-07-12 22:36:04 +08:00
|
|
|
const agent = stubAgent('reentrant')
|
|
|
|
|
ctx.on('agent/created', () => {
|
|
|
|
|
order.push(`first:${ctx.agents.get(agent.id) === agent}`)
|
2026-07-12 08:57:05 +08:00
|
|
|
detach()
|
2026-07-12 22:36:04 +08:00
|
|
|
order.push(`after-detach:${ctx.agents.get(agent.id) === agent}`)
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
2026-07-12 22:36:04 +08:00
|
|
|
ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`))
|
|
|
|
|
ctx.on('agent/disposed', () => void order.push('disposed'))
|
2026-07-14 07:13:42 +08:00
|
|
|
const detach = ctx.agents.enter(agent, undefined)
|
2026-07-12 08:57:05 +08:00
|
|
|
ctx.agents.announce(agent)
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed'])
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
|
|
|
|
})
|
2026-07-12 05:13:17 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('agentEvents()', () => {
|
2026-07-12 22:36:04 +08:00
|
|
|
it('contains each synchronous throw and returned-promise rejection', async () => {
|
2026-07-12 05:13:17 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
const warnings: string[] = []
|
|
|
|
|
const heard: string[] = []
|
2026-07-12 22:36:04 +08:00
|
|
|
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
|
|
|
|
const agent = stubAgent('event')
|
|
|
|
|
ctx.on('agent/status', () => { throw new Error('sync listener') })
|
2026-07-12 05:13:17 +08:00
|
|
|
ctx.on('agent/status', () => Promise.reject(new Error('async listener')) as never)
|
2026-07-12 22:36:04 +08:00
|
|
|
ctx.on('agent/status', (_agent, status) => void heard.push(status))
|
2026-07-12 05:13:17 +08:00
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
agentEvents(ctx, agent).emit('agent/status', 'running')
|
2026-07-12 05:13:17 +08:00
|
|
|
await Promise.resolve()
|
|
|
|
|
expect(heard).toEqual(['running'])
|
|
|
|
|
expect(warnings).toEqual([
|
2026-07-12 22:36:04 +08:00
|
|
|
'agent event "agent/status" listener threw: Error: sync listener',
|
2026-07-12 05:13:17 +08:00
|
|
|
'agent event "agent/status" listener rejected: Error: async listener',
|
|
|
|
|
])
|
|
|
|
|
})
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
})
|
2026-06-15 21:12:14 +08:00
|
|
|
|
2026-07-20 21:38:49 +08:00
|
|
|
describe('explicit cancellation helpers', () => {
|
2026-07-21 12:14:53 +08:00
|
|
|
it('exposes the closed typed cancellation cause at the Agent seam', () => {
|
|
|
|
|
expectTypeOf<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause | undefined>()
|
2026-07-21 12:48:46 +08:00
|
|
|
expectTypeOf<Parameters<Events['agent/cancel-requested']>[1]>().toEqualTypeOf<AgentCancelCause>()
|
2026-07-20 21:38:49 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reads only supported reasons from an explicit signal', () => {
|
2026-07-21 12:14:53 +08:00
|
|
|
const read = (reason: unknown) => {
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
controller.abort(reason)
|
|
|
|
|
return agentInterruptReasonOf(controller.signal)
|
|
|
|
|
}
|
2026-07-20 21:38:49 +08:00
|
|
|
const live = new AbortController()
|
|
|
|
|
expect(agentInterruptReasonOf(live.signal)).toBeUndefined()
|
|
|
|
|
|
2026-07-21 12:14:53 +08:00
|
|
|
expect(read({ kind: 'user' })).toEqual({ kind: 'user' })
|
|
|
|
|
expect(read({ kind: 'parent' })).toEqual({ kind: 'parent' })
|
2026-07-20 21:38:49 +08:00
|
|
|
|
|
|
|
|
const disposed = new AbortController()
|
|
|
|
|
disposed.abort(Object.assign(Object.create(null) as object, { kind: 'disposed' }))
|
|
|
|
|
const disposedReason = agentInterruptReasonOf(disposed.signal)
|
|
|
|
|
expect(disposedReason).toEqual({ kind: 'disposed' })
|
|
|
|
|
expect(Object.isFrozen(disposedReason)).toBe(true)
|
|
|
|
|
|
2026-07-21 12:14:53 +08:00
|
|
|
expect(read(null)).toBeUndefined()
|
|
|
|
|
expect(read([])).toBeUndefined()
|
|
|
|
|
expect(read('private runtime reason')).toBeUndefined()
|
|
|
|
|
expect(read(new Error('private runtime reason'))).toBeUndefined()
|
|
|
|
|
expect(read({ kind: 'user', detail: true })).toBeUndefined()
|
|
|
|
|
expect(read({ other: 'user' })).toBeUndefined()
|
|
|
|
|
expect(read({ kind: 'timeout' })).toBeUndefined()
|
2026-07-20 21:38:49 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-15 21:12:14 +08:00
|
|
|
describe('AgentRegistry factory seam', () => {
|
|
|
|
|
function stubFactory() {
|
2026-07-12 08:57:05 +08:00
|
|
|
const calls: {
|
|
|
|
|
create: Array<{ ownerCtx: Context; options: CreateAgentOptions }>
|
|
|
|
|
resume: Array<{ ownerCtx: Context; options: ResumeAgentOptions }>
|
|
|
|
|
} = { create: [], resume: [] }
|
|
|
|
|
const factory: AgentFactory = {
|
|
|
|
|
async createAgent(ownerCtx, options) {
|
|
|
|
|
calls.create.push({ ownerCtx, options })
|
2026-07-14 01:59:21 +08:00
|
|
|
return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() }
|
feat(agent): return an AgentHandle with an async per-agent disposer
The agent factory (`ctx.agents.create`/`resume`, the `AgentFactory` seam)
now returns `AgentHandle = { agent; dispose(): Promise<void> }` instead of a
bare `Agent`. The disposer is a capability: only the holder can tear down
exactly this agent — stop its loop, await the loop's exit (true quiescence,
not just the `disposed` status flip), unregister it, and remove its session
from the store.
The teardown ORDER is load-bearing for durability. The loop appends its
final `turn/end` + runs `session/flush` AFTER an abort, delivered through
`session.onAppend` → `session/event`; if the session-store effect (which
detaches `onAppend`) were torn down first, those closing events would never
reach persistence. So `dispose()`:
1. runs the register+start effect disposer (sync: request loop stop),
2. `await agent.done` (loop exits, final flush captured), THEN
3. runs the session disposer (detach onAppend + delete store entry).
`SessionStore.createOwned()` exposes the session-create effect's disposer
(plain `create()` discards it — fiber-owned). `AgentLoop` funnels both
factory entrypoints (`createAgent`, `resumeWith`) through a shared
`startOwned` that composes the ordered teardown; the config path keeps a
fiber-owned agent by discarding the handle.
`ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for
the owner that created it.
2026-06-20 06:44:35 +08:00
|
|
|
},
|
2026-07-12 22:36:04 +08:00
|
|
|
async resume(ownerCtx, options) {
|
2026-07-12 08:57:05 +08:00
|
|
|
calls.resume.push({ ownerCtx, options })
|
2026-07-14 01:59:21 +08:00
|
|
|
return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() }
|
feat(agent): return an AgentHandle with an async per-agent disposer
The agent factory (`ctx.agents.create`/`resume`, the `AgentFactory` seam)
now returns `AgentHandle = { agent; dispose(): Promise<void> }` instead of a
bare `Agent`. The disposer is a capability: only the holder can tear down
exactly this agent — stop its loop, await the loop's exit (true quiescence,
not just the `disposed` status flip), unregister it, and remove its session
from the store.
The teardown ORDER is load-bearing for durability. The loop appends its
final `turn/end` + runs `session/flush` AFTER an abort, delivered through
`session.onAppend` → `session/event`; if the session-store effect (which
detaches `onAppend`) were torn down first, those closing events would never
reach persistence. So `dispose()`:
1. runs the register+start effect disposer (sync: request loop stop),
2. `await agent.done` (loop exits, final flush captured), THEN
3. runs the session disposer (detach onAppend + delete store entry).
`SessionStore.createOwned()` exposes the session-create effect's disposer
(plain `create()` discards it — fiber-owned). `AgentLoop` funnels both
factory entrypoints (`createAgent`, `resumeWith`) through a shared
`startOwned` that composes the ordered teardown; the config path keeps a
fiber-owned agent by discarding the handle.
`ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for
the owner that created it.
2026-06-20 06:44:35 +08:00
|
|
|
},
|
2026-06-15 21:12:14 +08:00
|
|
|
}
|
|
|
|
|
return { factory, calls }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
it('requires a factory and delegates through the calling context', async () => {
|
2026-06-15 21:12:14 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
2026-07-14 01:59:21 +08:00
|
|
|
await expect(ctx.agents.create({ sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
|
2026-06-15 21:12:14 +08:00
|
|
|
const { factory, calls } = stubFactory()
|
|
|
|
|
ctx.agents.setFactory(factory)
|
|
|
|
|
|
2026-07-12 08:57:05 +08:00
|
|
|
let callerFiber: Context['fiber'] | undefined
|
2026-07-12 22:36:04 +08:00
|
|
|
await ctx.plugin(Object.assign(async (inner: Context) => {
|
2026-07-12 08:57:05 +08:00
|
|
|
callerFiber = inner.fiber
|
2026-07-14 01:59:21 +08:00
|
|
|
await inner.agents.create({ sessionId: SessionId('create-s') })
|
|
|
|
|
await inner.agents.resume({ resumeSessionId: SessionId('resume-s') })
|
2026-07-12 08:57:05 +08:00
|
|
|
}, { inject: ['agents'] }))
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber)
|
|
|
|
|
expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber)
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
it('rejects a second factory and clears the slot with its owner (HMR)', async () => {
|
2026-07-12 08:57:05 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
2026-07-12 22:36:04 +08:00
|
|
|
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
|
|
|
|
inner.agents.setFactory(stubFactory().factory)
|
|
|
|
|
expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
|
2026-06-15 21:12:14 +08:00
|
|
|
}, { inject: ['agents'] }))
|
2026-07-14 01:59:21 +08:00
|
|
|
await expect(ctx.agents.create({ sessionId: SessionId('before-s') })).resolves.toBeDefined()
|
2026-07-12 22:36:04 +08:00
|
|
|
await owner.dispose()
|
2026-07-14 01:59:21 +08:00
|
|
|
await expect(ctx.agents.create({ sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/)
|
2026-06-15 21:12:14 +08:00
|
|
|
})
|
2026-07-12 08:57:05 +08:00
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
it('canonicalizes an already traced Service before tracing it for the caller', async () => {
|
2026-07-12 08:57:05 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
const states = new WeakMap<object, string[]>()
|
|
|
|
|
class TracedFactory extends Service implements AgentFactory {
|
|
|
|
|
constructor(inner: Context) {
|
|
|
|
|
super(inner, 'tracedFactory')
|
|
|
|
|
states.set(this, [])
|
|
|
|
|
}
|
|
|
|
|
private calls(): string[] {
|
|
|
|
|
const original = (this as unknown as { [symbols.original]?: TracedFactory })[symbols.original] ?? this
|
|
|
|
|
const calls = states.get(original)
|
2026-07-12 22:36:04 +08:00
|
|
|
if (calls === undefined) throw new Error('factory receiver was not canonicalized')
|
2026-07-12 08:57:05 +08:00
|
|
|
return calls
|
|
|
|
|
}
|
2026-07-12 22:36:04 +08:00
|
|
|
async createAgent(_ownerCtx: Context, options: CreateAgentOptions) {
|
2026-07-12 08:57:05 +08:00
|
|
|
this.calls().push('create')
|
2026-07-14 01:59:21 +08:00
|
|
|
return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() }
|
2026-07-12 08:57:05 +08:00
|
|
|
}
|
2026-07-12 22:36:04 +08:00
|
|
|
async resume(_ownerCtx: Context, options: ResumeAgentOptions) {
|
2026-07-12 08:57:05 +08:00
|
|
|
this.calls().push('resume')
|
2026-07-14 01:59:21 +08:00
|
|
|
return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() }
|
2026-07-12 08:57:05 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
await ctx.plugin(TracedFactory)
|
|
|
|
|
const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory
|
|
|
|
|
ctx.agents.setFactory(traced)
|
2026-07-14 01:59:21 +08:00
|
|
|
await ctx.agents.create({ sessionId: SessionId('create-s') })
|
|
|
|
|
await ctx.agents.resume({ resumeSessionId: SessionId('resume-s') })
|
2026-07-12 08:57:05 +08:00
|
|
|
const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original]
|
|
|
|
|
expect(states.get(raw!)).toEqual(['create', 'resume'])
|
|
|
|
|
})
|
2026-06-15 21:12:14 +08:00
|
|
|
})
|