2026-07-28 13:55:59 +08:00
|
|
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
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
|
|
|
import { describe, expect, it } from 'vitest'
|
build(vendor): rescope the vendored Cordis packages into @deepseek-ai
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it
prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`,
`verify-translation-pairing --write` for the touched bilingual pairs,
`gen-doc-graphs`, and one typert snapshot whose ids embed character offsets.
`pnpm run rescope-vendor --check` verifies the result.
Renames nine vendored packages (cordis, cosmokit, schemastery and the six
@cordisjs plugins) and every reference that resolves them: manifest names and
dependency keys, module specifiers including declare-module merges, cordis.yml
plugin names, tsconfig paths, every Markdown fence, and `docs/` prose.
Directory names, upstream versions, and dependency ranges are unchanged, so
vendor/README.md still reads as an upstream snapshot; its manifest table gains
an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed
at each fork's origin.
The tutorial tier follows the rename end to end: its yaml fences named plugins
the Loader can no longer resolve, its `ts ignore-check` fences disagreed with
the compiled fences beside them, and its prose quoted both. The contracts that
told readers to keep upstream names — the root convention and the vendoring
cookbook's tree comment and manifest invariant — now say to rescope instead.
Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle
purity gate now names the vendored libraries a browser bundle inlines, and the
files where a bare `cordis` is an agent-preset id keep that product data.
2026-08-10 22:04:06 +08:00
|
|
|
import { Context, symbols, type EffectMeta, type Fiber } from '@deepseek-ai/cordis'
|
2026-08-13 00:36:22 +08:00
|
|
|
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
2026-07-12 03:51:55 +08:00
|
|
|
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
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
|
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
2026-08-13 00:36:22 +08:00
|
|
|
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
2026-07-14 01:59:21 +08:00
|
|
|
import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
|
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
|
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
2026-07-14 02:32:35 +08:00
|
|
|
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
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
|
|
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
|
|
|
|
|
2026-07-12 08:57:05 +08:00
|
|
|
async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise<{ ctx: Context; loopFiber: Fiber }> {
|
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
|
|
|
const ctx = new Context()
|
2026-08-13 00:36:22 +08:00
|
|
|
await ctx.plugin(LlmRuntime)
|
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
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' })
|
2026-08-13 00:36:22 +08:00
|
|
|
await ctx.plugin(ToolRuntime)
|
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
|
|
|
await ctx.plugin(AgentRegistry)
|
2026-07-12 08:57:05 +08:00
|
|
|
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
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.llm.registerAdapter(['mock'], adapter)
|
2026-07-12 08:57:05 +08:00
|
|
|
return { ctx, loopFiber }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise<Context> {
|
|
|
|
|
return (await harnessWithLoop(adapter)).ctx
|
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
|
|
|
}
|
|
|
|
|
|
2026-07-14 02:32:35 +08:00
|
|
|
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
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
|
|
|
return new Promise((resolve) => {
|
2026-08-06 12:13:14 +08:00
|
|
|
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
|
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
|
|
|
if (subject === agent && status === 'idle') {
|
|
|
|
|
dispose()
|
|
|
|
|
resolve()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
|
|
|
|
|
|
2026-07-12 23:15:07 +08:00
|
|
|
/** Throw an arbitrary callback value to exercise the public unknown-error boundary. */
|
|
|
|
|
function throwUnknown(value: unknown): never {
|
|
|
|
|
throw value
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 08:57:05 +08:00
|
|
|
/** Invoke the exact lifecycle effect to exercise same-stack reentrant teardown. */
|
|
|
|
|
function disposeCurrentLifecycle(ownerCtx: Context): void {
|
|
|
|
|
const lifecycle = [...ownerCtx.fiber._disposables]
|
|
|
|
|
.find((dispose) => {
|
|
|
|
|
const effect = (dispose as typeof dispose & { [symbols.effect]?: EffectMeta })[symbols.effect]
|
2026-07-12 22:36:04 +08:00
|
|
|
return effect?.label.startsWith('agentLoop.lifecycle(') === true
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
|
|
|
|
if (lifecycle === undefined) throw new Error('agent lifecycle effect not found')
|
|
|
|
|
void lifecycle()
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
describe('agent scope lifecycle', () => {
|
2026-07-14 01:59:21 +08:00
|
|
|
it('rejects an already-aborted creation signal before publishing either object', async () => {
|
2026-07-12 23:15:07 +08:00
|
|
|
const ctx = await harness()
|
|
|
|
|
const reason = new Error('cancelled before creation')
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
controller.abort(reason)
|
|
|
|
|
|
|
|
|
|
await expect(ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('pre-aborted-s'),
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
})).rejects.toBe(reason)
|
|
|
|
|
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('pre-aborted-s'))).toBeUndefined()
|
2026-07-12 23:15:07 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('pre-aborted-s'))).toBeUndefined()
|
|
|
|
|
|
|
|
|
|
const valueController = new AbortController()
|
|
|
|
|
valueController.abort('plain cancellation reason')
|
|
|
|
|
await expect(ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('pre-aborted-value-s'),
|
|
|
|
|
signal: valueController.signal,
|
|
|
|
|
})).rejects.toMatchObject({
|
2026-07-14 01:59:21 +08:00
|
|
|
message: 'agent "pre-aborted-value-s" creation aborted',
|
2026-07-12 23:15:07 +08:00
|
|
|
cause: 'plain cancellation reason',
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('pre-aborted-value-s'))).toBeUndefined()
|
2026-07-12 23:15:07 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('pre-aborted-value-s'))).toBeUndefined()
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('joins cleanup when an abort lands reentrantly during scope preparation', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const reason = new Error('cancelled while preparing')
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
let aborted = false
|
|
|
|
|
ctx.on('internal/plugin', (fiber) => {
|
|
|
|
|
if (aborted || fiber.name !== 'scope') return
|
|
|
|
|
aborted = true
|
|
|
|
|
controller.abort(reason)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('prepare-abort-s'),
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
})).rejects.toBe(reason)
|
|
|
|
|
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('prepare-abort-s'))).toBeUndefined()
|
2026-07-12 23:15:07 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('prepare-abort-s'))).toBeUndefined()
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('normalizes non-Error create failures for rollback while rethrowing the original value', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
let thrown: unknown
|
|
|
|
|
ctx.on('session/created', () => {
|
|
|
|
|
if (thrown === undefined) return
|
|
|
|
|
const value = thrown
|
|
|
|
|
thrown = undefined
|
|
|
|
|
throwUnknown(value)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const createFailure = { source: 'create' }
|
|
|
|
|
thrown = createFailure
|
|
|
|
|
let createCaught: unknown
|
|
|
|
|
try {
|
2026-07-14 01:59:21 +08:00
|
|
|
ctx.agentLoop.create(SessionId('unknown-create'))
|
2026-07-12 23:15:07 +08:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
createCaught = error
|
|
|
|
|
}
|
|
|
|
|
expect(createCaught).toBe(createFailure)
|
|
|
|
|
|
|
|
|
|
const ownedFailure = { source: 'createAgent' }
|
|
|
|
|
thrown = ownedFailure
|
|
|
|
|
await expect(ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('unknown-owned-create-s'),
|
|
|
|
|
})).rejects.toBe(ownedFailure)
|
|
|
|
|
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('unknown-create'))).toBeUndefined()
|
|
|
|
|
expect(ctx.agents.get(SessionId('unknown-owned-create-s'))).toBeUndefined()
|
2026-07-12 23:15:07 +08:00
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
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
|
|
|
it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => {
|
|
|
|
|
const ctx = await harness()
|
2026-07-18 12:21:15 +08:00
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
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
|
|
|
expect(scopeOf(agent.ctx)).toBe(agent)
|
|
|
|
|
expect(agent.ctx.agent).toBe(agent)
|
|
|
|
|
// The root accessor default: a plain context answers undefined, not a throw.
|
|
|
|
|
expect(ctx.agent).toBeUndefined()
|
2026-07-30 13:49:57 +08:00
|
|
|
await agent.whenIdle()
|
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
|
|
|
})
|
|
|
|
|
|
2026-07-14 07:13:42 +08:00
|
|
|
it('records agents created through an agent context as non-root runtime children', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const root = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('runtime-root'),
|
|
|
|
|
agentOptions: { model: 'mock' },
|
|
|
|
|
})
|
|
|
|
|
const child = await root.agent.ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('runtime-child'),
|
|
|
|
|
agentOptions: { model: 'mock' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(ctx.agents.list()).toEqual([root.agent, child.agent])
|
|
|
|
|
expect(ctx.agents.roots()).toEqual([root.agent])
|
|
|
|
|
|
|
|
|
|
await child.dispose()
|
|
|
|
|
await root.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
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
|
|
|
it('scoped registrations live in the agent world and die with the agent', async () => {
|
|
|
|
|
const ctx = await harness()
|
2026-07-18 12:21:15 +08:00
|
|
|
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } })
|
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
|
|
|
const { agent } = handle
|
|
|
|
|
agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
|
2026-07-21 03:08:35 +08:00
|
|
|
agent.ctx.tools.register(defineContentToolFixture({
|
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
|
|
|
name: 'mine', description: 'scoped', parameters: {},
|
|
|
|
|
execute: () => Promise.resolve(text('ran')),
|
2026-07-21 03:08:35 +08:00
|
|
|
}))
|
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
|
|
|
|
|
|
|
|
const scopedAssembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
|
|
|
|
|
expect(scopedAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You run tests.')
|
|
|
|
|
expect(scopedAssembly.tools.map(t => t.name)).toContain('mine')
|
|
|
|
|
// Other assemblies are untouched.
|
|
|
|
|
const globalAssembly = await ctx.systemPrompt.assemble()
|
|
|
|
|
expect(globalAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.')
|
|
|
|
|
expect(globalAssembly.tools.map(t => t.name)).not.toContain('mine')
|
|
|
|
|
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
// The scoped world unwound with the agent: nothing leaked into the registries.
|
|
|
|
|
expect(ctx.tools.get('mine', agent)).toBeUndefined()
|
|
|
|
|
const after = await ctx.systemPrompt.assemble(assembleContextFor(agent))
|
|
|
|
|
expect(after.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => {
|
|
|
|
|
const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')]))
|
2026-07-18 12:21:15 +08:00
|
|
|
const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' })
|
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
|
|
|
|
|
|
|
|
const heard: string[] = []
|
2026-08-06 12:13:14 +08:00
|
|
|
a.ctx.on('agent/status', ({ agent: subject, status }) => void heard.push(`a-sees:${subject.id}:${status}`))
|
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
|
|
|
a.ctx.on('session/event', (_s, event) => {
|
|
|
|
|
if (event.type === 'user/message') heard.push('a-sees:user-message')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-28 13:55:59 +08:00
|
|
|
b.followup(createUserMessage({ content: text('for b'), source: { kind: 'user' } }))
|
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
|
|
|
await waitForIdle(ctx, b)
|
|
|
|
|
expect(heard).toEqual([]) // nothing of b's leaked into a's scope
|
|
|
|
|
|
2026-07-28 13:55:59 +08:00
|
|
|
a.followup(createUserMessage({ content: text('for a'), source: { kind: 'user' } }))
|
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
|
|
|
await waitForIdle(ctx, a)
|
|
|
|
|
expect(heard).toContain('a-sees:a:running')
|
|
|
|
|
expect(heard).toContain('a-sees:user-message')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const order: string[] = []
|
2026-08-06 12:13:14 +08:00
|
|
|
ctx.on('agent/session-start', ({ agent }) => {
|
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
|
|
|
order.push('session-start')
|
|
|
|
|
// The scoped section is already registered by the time session-start fires.
|
|
|
|
|
void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => {
|
|
|
|
|
order.push(`persona:${assembly.sections.find(s => s.name === 'deployment:persona')?.text}`)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 22:55:26 +08:00
|
|
|
const handle = await ctx.agents.create({
|
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
|
|
|
sessionId: SessionId('child-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-11 22:55:26 +08:00
|
|
|
setup: async (agentCtx) => {
|
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
|
|
|
order.push('setup')
|
2026-07-11 22:55:26 +08:00
|
|
|
await Promise.resolve()
|
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
|
|
|
agentCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You are the child.' })
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
|
|
expect(order).toEqual(['setup', 'session-start', 'persona:You are the child.'])
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-14 01:59:21 +08:00
|
|
|
it('keeps both objects unpublished until async setup completes, then announces in order', async () => {
|
2026-07-11 22:55:26 +08:00
|
|
|
const ctx = await harness()
|
|
|
|
|
const gate = Promise.withResolvers<undefined>()
|
|
|
|
|
const setupStarted = Promise.withResolvers<undefined>()
|
|
|
|
|
const order: string[] = []
|
|
|
|
|
ctx.on('session/created', (session) => {
|
|
|
|
|
expect(ctx.sessions.get(session.id)).toBe(session)
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(session.id)?.session).toBe(session)
|
2026-07-11 22:55:26 +08:00
|
|
|
order.push('session/created')
|
|
|
|
|
})
|
|
|
|
|
ctx.on('agent/created', () => void order.push('agent/created'))
|
|
|
|
|
ctx.on('agent/session-start', () => void order.push('agent/session-start'))
|
2026-07-14 21:57:52 +08:00
|
|
|
const acceptedOptions = { provider: 'mock', model: 'mock' }
|
2026-07-11 22:55:26 +08:00
|
|
|
|
|
|
|
|
const creating = ctx.agents.create({
|
2026-07-14 01:59:21 +08:00
|
|
|
sessionId: SessionId('atomic'),
|
2026-07-11 22:55:26 +08:00
|
|
|
agentOptions: acceptedOptions,
|
|
|
|
|
setup: async (agentCtx) => {
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(agentCtx.agent?.id).toBe(SessionId('atomic'))
|
2026-07-11 22:55:26 +08:00
|
|
|
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
|
|
|
|
|
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
|
|
|
|
|
order.push('setup:start')
|
|
|
|
|
setupStarted.resolve(undefined)
|
|
|
|
|
await gate.promise
|
|
|
|
|
order.push('setup:end')
|
fix(agent): commit mutable setup at publication
Agent setup may await while a mutable contribution registry changes. The previous subagent path validated and committed its provisioning batch inside the setup callback. A revocation queued after that callback returned therefore treated the installation as resident and released it, even though AgentLoop had not published the child yet. AgentLoop could then admit and announce a child whose required capability had already disappeared.
Introduce AgentSetupCommit as the optional synchronous result of create and resume setup. AgentLoop now awaits setup, invokes that commit with no intervening asynchronous boundary, and only then enters the Session and Agent registries. A commit failure follows the existing private-transaction rollback, so neither identity is published and the caller can reuse the id.
Keep continuable-subagent installations provisional until this publication commit. Contribution removal still releases every installation immediately, but now marks an unpublished batch invalid so its commit rejects with ACTIVATION_SETUP_REVOKED. Once the commit succeeds, later removal remains ordinary live revocation.
Cover create and resume ordering, resume commit rejection and identity reuse, and an assembled microtask revocation that leaves only the parent Agent and Session. Update the public JSDoc, architecture flow, package contracts, current Agent Notes, Chinese counterparts, pairing records, and generated Cordis API to describe the new boundary.
Validated with the four focused Agent/subagent test files (91 tests), the isolated assembled regression, targeted TypeScript project builds, generated Cordis API freshness, export JSDoc verification, scoped translation pairing, Markdown wrapping, and Mermaid parsing.
2026-08-02 20:09:05 +08:00
|
|
|
return {
|
|
|
|
|
commit: () => {
|
|
|
|
|
expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined()
|
|
|
|
|
expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined()
|
|
|
|
|
order.push('setup:commit')
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-07-11 22:55:26 +08:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
await setupStarted.promise
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined()
|
2026-07-14 05:41:01 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined()
|
2026-07-11 22:55:26 +08:00
|
|
|
expect(order).toEqual(['setup:start'])
|
|
|
|
|
gate.resolve(undefined)
|
|
|
|
|
const handle = await creating
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(handle.agent.options).toBe(acceptedOptions)
|
2026-07-11 22:55:26 +08:00
|
|
|
expect(order).toEqual([
|
|
|
|
|
'setup:start',
|
|
|
|
|
'setup:end',
|
fix(agent): commit mutable setup at publication
Agent setup may await while a mutable contribution registry changes. The previous subagent path validated and committed its provisioning batch inside the setup callback. A revocation queued after that callback returned therefore treated the installation as resident and released it, even though AgentLoop had not published the child yet. AgentLoop could then admit and announce a child whose required capability had already disappeared.
Introduce AgentSetupCommit as the optional synchronous result of create and resume setup. AgentLoop now awaits setup, invokes that commit with no intervening asynchronous boundary, and only then enters the Session and Agent registries. A commit failure follows the existing private-transaction rollback, so neither identity is published and the caller can reuse the id.
Keep continuable-subagent installations provisional until this publication commit. Contribution removal still releases every installation immediately, but now marks an unpublished batch invalid so its commit rejects with ACTIVATION_SETUP_REVOKED. Once the commit succeeds, later removal remains ordinary live revocation.
Cover create and resume ordering, resume commit rejection and identity reuse, and an assembled microtask revocation that leaves only the parent Agent and Session. Update the public JSDoc, architecture flow, package contracts, current Agent Notes, Chinese counterparts, pairing records, and generated Cordis API to describe the new boundary.
Validated with the four focused Agent/subagent test files (91 tests), the isolated assembled regression, targeted TypeScript project builds, generated Cordis API freshness, export JSDoc verification, scoped translation pairing, Markdown wrapping, and Mermaid parsing.
2026-08-02 20:09:05 +08:00
|
|
|
'setup:commit',
|
2026-07-11 22:55:26 +08:00
|
|
|
'session/created',
|
|
|
|
|
'setup-listener:session/created',
|
|
|
|
|
'agent/created',
|
|
|
|
|
'setup-listener:agent/created',
|
|
|
|
|
'agent/session-start',
|
|
|
|
|
])
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
it('lets the final enter arbitrate unsupported concurrent same-id creation and rolls the loser back', async () => {
|
2026-07-11 22:55:26 +08:00
|
|
|
const ctx = await harness()
|
|
|
|
|
const gate = Promise.withResolvers<undefined>()
|
2026-07-12 22:36:04 +08:00
|
|
|
const bothStarted = Promise.withResolvers<undefined>()
|
|
|
|
|
let started = 0
|
|
|
|
|
const setup = async (): Promise<void> => {
|
|
|
|
|
started += 1
|
|
|
|
|
if (started === 2) bothStarted.resolve(undefined)
|
|
|
|
|
await gate.promise
|
|
|
|
|
}
|
2026-07-14 01:59:21 +08:00
|
|
|
const sessionId = SessionId('concurrent-final-enter')
|
2026-07-11 22:55:26 +08:00
|
|
|
const first = ctx.agents.create({
|
2026-07-14 01:59:21 +08:00
|
|
|
sessionId,
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 22:36:04 +08:00
|
|
|
setup,
|
2026-07-11 22:55:26 +08:00
|
|
|
})
|
2026-07-12 22:36:04 +08:00
|
|
|
const second = ctx.agents.create({
|
2026-07-14 01:59:21 +08:00
|
|
|
sessionId,
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 22:36:04 +08:00
|
|
|
setup,
|
|
|
|
|
})
|
|
|
|
|
await bothStarted.promise
|
2026-07-11 22:55:26 +08:00
|
|
|
expect(ctx.agents.list()).toEqual([])
|
|
|
|
|
expect(ctx.sessions.list()).toEqual([])
|
|
|
|
|
|
|
|
|
|
gate.resolve(undefined)
|
2026-07-12 22:36:04 +08:00
|
|
|
const outcomes = await Promise.allSettled([first, second])
|
|
|
|
|
const fulfilled = outcomes.filter((outcome): outcome is PromiseFulfilledResult<Awaited<typeof first>> => outcome.status === 'fulfilled')
|
|
|
|
|
const rejected = outcomes.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected')
|
|
|
|
|
expect(fulfilled).toHaveLength(1)
|
|
|
|
|
expect(rejected).toHaveLength(1)
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(String(rejected[0]!.reason)).toMatch(/already exists/)
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(ctx.agents.list()).toEqual([fulfilled[0]!.value.agent])
|
|
|
|
|
expect(ctx.sessions.list()).toEqual([fulfilled[0]!.value.agent.session])
|
|
|
|
|
|
|
|
|
|
await fulfilled[0]!.value.dispose()
|
|
|
|
|
expect(ctx.agents.list()).toEqual([])
|
|
|
|
|
expect(ctx.sessions.list()).toEqual([])
|
2026-07-11 22:55:26 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
it('uses signal only for creation: aborts pending setup but not a returned live handle', async () => {
|
2026-07-12 05:13:17 +08:00
|
|
|
const ctx = await harness()
|
2026-07-12 22:36:04 +08:00
|
|
|
const pendingController = new AbortController()
|
|
|
|
|
const setupStarted = Promise.withResolvers<undefined>()
|
|
|
|
|
const pending = ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('signal-pending-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 22:36:04 +08:00
|
|
|
signal: pendingController.signal,
|
|
|
|
|
setup: async () => {
|
|
|
|
|
setupStarted.resolve(undefined)
|
|
|
|
|
await new Promise<never>(() => {})
|
2026-07-12 05:13:17 +08:00
|
|
|
},
|
|
|
|
|
})
|
2026-07-12 22:36:04 +08:00
|
|
|
await setupStarted.promise
|
|
|
|
|
pendingController.abort(new Error('cancel pending creation'))
|
|
|
|
|
await expect(pending).rejects.toThrow('cancel pending creation')
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('signal-pending-s'))).toBeUndefined()
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('signal-pending-s'))).toBeUndefined()
|
|
|
|
|
|
|
|
|
|
const liveController = new AbortController()
|
|
|
|
|
const live = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('signal-live-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 22:36:04 +08:00
|
|
|
signal: liveController.signal,
|
2026-07-11 22:55:26 +08:00
|
|
|
})
|
2026-07-12 22:36:04 +08:00
|
|
|
liveController.abort(new Error('too late'))
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
expect(ctx.agents.get(live.agent.id)).toBe(live.agent)
|
|
|
|
|
expect(live.agent.status).toBe('idle')
|
|
|
|
|
await live.dispose()
|
2026-07-11 22:55:26 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('owner unload aborts a pending setup and publishes nothing', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const gate = Promise.withResolvers<undefined>()
|
|
|
|
|
const setupStarted = Promise.withResolvers<undefined>()
|
|
|
|
|
const published: string[] = []
|
|
|
|
|
ctx.on('session/created', () => void published.push('session/created'))
|
|
|
|
|
ctx.on('agent/created', () => void published.push('agent/created'))
|
|
|
|
|
|
|
|
|
|
let creating!: ReturnType<typeof ctx.agents.create>
|
|
|
|
|
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
|
|
|
|
creating = inner.agents.create({
|
|
|
|
|
sessionId: SessionId('owner-race-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-11 22:55:26 +08:00
|
|
|
setup: async () => {
|
|
|
|
|
setupStarted.resolve(undefined)
|
|
|
|
|
await gate.promise
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}, { inject: ['agents'] }))
|
|
|
|
|
await setupStarted.promise
|
|
|
|
|
|
|
|
|
|
await owner.dispose()
|
|
|
|
|
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
|
|
|
|
expect(published).toEqual([])
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('owner-race-s'))).toBeUndefined()
|
2026-07-11 22:55:26 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined()
|
|
|
|
|
// Let the losing callback settle; Promise.race already observes it.
|
|
|
|
|
gate.resolve(undefined)
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
|
|
|
|
|
// The other ordering in the same race: setup resolves first (its reaction
|
|
|
|
|
// is queued), then owner disposal flips active before that continuation can
|
|
|
|
|
// publish. The post-race active check must still reject.
|
|
|
|
|
const gate2 = Promise.withResolvers<undefined>()
|
|
|
|
|
const setupStarted2 = Promise.withResolvers<undefined>()
|
|
|
|
|
let creating2!: ReturnType<typeof ctx.agents.create>
|
|
|
|
|
const owner2 = await ctx.plugin(Object.assign((inner: Context) => {
|
|
|
|
|
creating2 = inner.agents.create({
|
|
|
|
|
sessionId: SessionId('owner-race-s-2'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-11 22:55:26 +08:00
|
|
|
setup: async () => {
|
|
|
|
|
setupStarted2.resolve(undefined)
|
|
|
|
|
await gate2.promise
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}, { inject: ['agents'] }))
|
|
|
|
|
await setupStarted2.promise
|
|
|
|
|
gate2.resolve(undefined)
|
|
|
|
|
const unload2 = owner2.dispose()
|
|
|
|
|
await expect(creating2).rejects.toThrow(/owner disposed during setup/)
|
|
|
|
|
await unload2
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('owner-race-s-2'))).toBeUndefined()
|
2026-07-11 22:55:26 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 08:57:05 +08:00
|
|
|
it('an AgentLoop unload aborts pending setup, awaits cleanup, and releases both ids', async () => {
|
|
|
|
|
const { ctx, loopFiber } = await harnessWithLoop()
|
|
|
|
|
const gate = Promise.withResolvers<undefined>()
|
|
|
|
|
const setupStarted = Promise.withResolvers<undefined>()
|
|
|
|
|
const published: string[] = []
|
|
|
|
|
ctx.on('session/created', () => void published.push('session/created'))
|
|
|
|
|
ctx.on('agent/created', () => void published.push('agent/created'))
|
|
|
|
|
|
|
|
|
|
const creating = ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('factory-setup-race-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 08:57:05 +08:00
|
|
|
setup: async () => {
|
|
|
|
|
setupStarted.resolve(undefined)
|
|
|
|
|
await gate.promise
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
await setupStarted.promise
|
|
|
|
|
|
|
|
|
|
await loopFiber.dispose()
|
2026-07-12 22:36:04 +08:00
|
|
|
await expect(creating).rejects.toThrow(/agent loop is not active/)
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(published).toEqual([])
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('factory-setup-race-s'))).toBeUndefined()
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined()
|
|
|
|
|
|
|
|
|
|
gate.resolve(undefined)
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('factory unload during scope minting skips setup and awaits provisional cleanup', async () => {
|
|
|
|
|
const { ctx, loopFiber } = await harnessWithLoop()
|
|
|
|
|
let unloaded = false
|
|
|
|
|
let setupCalls = 0
|
|
|
|
|
ctx.on('internal/plugin', (fiber) => {
|
|
|
|
|
if (unloaded || fiber.name !== 'scope') return
|
|
|
|
|
unloaded = true
|
|
|
|
|
void loopFiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const creating = ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('factory-scope-race-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 08:57:05 +08:00
|
|
|
setup: () => { setupCalls += 1 },
|
|
|
|
|
})
|
2026-07-12 22:36:04 +08:00
|
|
|
await expect(creating).rejects.toThrow(/agent loop is not active/)
|
2026-07-12 08:57:05 +08:00
|
|
|
await loopFiber.dispose()
|
2026-07-24 21:18:48 +08:00
|
|
|
expect(setupCalls).toBe(1)
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('factory-scope-race-s'))).toBeUndefined()
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined()
|
|
|
|
|
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('caller unload during scope minting owns and drains the half-built child', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const gate = Promise.withResolvers<undefined>()
|
|
|
|
|
const cleanupStarted = Promise.withResolvers<undefined>()
|
|
|
|
|
let ownerFiber!: Fiber
|
|
|
|
|
let ownerDisposal!: Promise<void>
|
|
|
|
|
let scopeFiber: Fiber | undefined
|
|
|
|
|
let creating!: ReturnType<typeof ctx.agents.create>
|
|
|
|
|
ctx.on('internal/plugin', (fiber) => {
|
|
|
|
|
if (fiber.name !== 'scope' || scopeFiber !== undefined) return
|
|
|
|
|
scopeFiber = fiber
|
|
|
|
|
fiber.ctx.effect(() => async () => {
|
|
|
|
|
cleanupStarted.resolve(undefined)
|
|
|
|
|
await gate.promise
|
|
|
|
|
})
|
|
|
|
|
ownerDisposal = ownerFiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const owner = ctx.plugin(Object.assign((inner: Context) => {
|
|
|
|
|
ownerFiber = inner.fiber
|
|
|
|
|
creating = inner.agents.create({
|
|
|
|
|
sessionId: SessionId('caller-scope-race-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
|
|
|
|
}, { inject: ['agents'] }))
|
|
|
|
|
|
|
|
|
|
await cleanupStarted.promise
|
|
|
|
|
let ownerSettled = false
|
|
|
|
|
void ownerDisposal.then(() => { ownerSettled = true })
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
expect(ownerSettled).toBe(false)
|
|
|
|
|
gate.resolve(undefined)
|
|
|
|
|
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
|
|
|
|
await ownerDisposal
|
|
|
|
|
await owner
|
|
|
|
|
expect(scopeFiber?.uid).toBeNull()
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('caller-scope-race-s'))).toBeUndefined()
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('caller-scope-race-s'))).toBeUndefined()
|
|
|
|
|
await owner.dispose()
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('synchronous create rechecks provider liveness before its first publication edge', async () => {
|
|
|
|
|
const { ctx, loopFiber } = await harnessWithLoop()
|
|
|
|
|
const sessionsBefore = ctx.sessions.list().length
|
|
|
|
|
let unloaded = false
|
2026-07-24 21:18:48 +08:00
|
|
|
let unloading!: Promise<void>
|
2026-07-12 08:57:05 +08:00
|
|
|
ctx.on('internal/plugin', (fiber) => {
|
|
|
|
|
if (unloaded || fiber.name !== 'scope') return
|
|
|
|
|
unloaded = true
|
2026-07-24 21:18:48 +08:00
|
|
|
unloading = loopFiber.dispose()
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-24 21:18:48 +08:00
|
|
|
ctx.agentLoop.create(SessionId('config-scope-race'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
await unloading
|
|
|
|
|
expect(ctx.agents.get(SessionId('config-scope-race')) === undefined).toBe(true)
|
|
|
|
|
expect(ctx.sessions.list().length).toBe(sessionsBefore)
|
2026-07-12 08:57:05 +08:00
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
it('synchronous create leaves no lifecycle state when session preparation fails', async () => {
|
2026-07-12 08:57:05 +08:00
|
|
|
const ctx = await harness()
|
2026-07-14 01:59:21 +08:00
|
|
|
const id = SessionId('config-prepare-failure')
|
2026-07-12 08:57:05 +08:00
|
|
|
|
2026-07-14 21:57:52 +08:00
|
|
|
expect(() => ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: 'relative' }))
|
2026-07-12 08:57:05 +08:00
|
|
|
.toThrow(/absolute path/)
|
2026-07-14 21:57:52 +08:00
|
|
|
const replacement = ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: '/recovered' })
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(ctx.agents.get(id)).toBe(replacement)
|
|
|
|
|
await replacement.whenIdle()
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
it('factory unload awaits provisional cleanup when scope preparation throws', async () => {
|
2026-07-12 08:57:05 +08:00
|
|
|
const { ctx, loopFiber } = await harnessWithLoop()
|
|
|
|
|
let triggered = false
|
|
|
|
|
ctx.on('internal/plugin', (fiber) => {
|
|
|
|
|
if (triggered || fiber.name !== 'scope') return
|
|
|
|
|
triggered = true
|
|
|
|
|
void loopFiber.dispose()
|
|
|
|
|
throw new Error('scope preparation failed')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('factory-scope-throw-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 08:57:05 +08:00
|
|
|
})).rejects.toThrow('scope preparation failed')
|
|
|
|
|
await loopFiber.dispose()
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
|
|
|
|
|
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => {
|
|
|
|
|
const { ctx, loopFiber } = await harnessWithLoop()
|
|
|
|
|
const loop = ctx.agentLoop
|
2026-07-14 01:59:21 +08:00
|
|
|
const sessionId = SessionId('factory-live')
|
2026-07-12 08:57:05 +08:00
|
|
|
const handle = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('factory-live-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await loopFiber.dispose()
|
2026-07-24 21:18:48 +08:00
|
|
|
expect(handle.agent.status).toBe('idle')
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
|
|
|
|
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
2026-07-24 21:18:48 +08:00
|
|
|
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.lifecycle(${sessionId})`)).toEqual([])
|
2026-07-12 08:57:05 +08:00
|
|
|
// The consumer handle shares the provider's completed quiescence boundary.
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
|
|
|
|
|
await expect(loop.createAgent(ctx, {
|
|
|
|
|
sessionId: SessionId('factory-inactive-s'),
|
2026-07-24 21:18:48 +08:00
|
|
|
})).rejects.toThrow(/agent loop is not active|inactive context/)
|
2026-07-12 08:57:05 +08:00
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps AgentLoop dependencies available when the caller injects only agents', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
let creating!: ReturnType<typeof ctx.agents.create>
|
|
|
|
|
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
|
|
|
|
creating = inner.agents.create({
|
|
|
|
|
sessionId: SessionId('dependency-origin-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 08:57:05 +08:00
|
|
|
setup: (agentCtx) => {
|
2026-07-21 03:08:35 +08:00
|
|
|
agentCtx.tools.register(defineContentToolFixture({
|
2026-07-12 08:57:05 +08:00
|
|
|
name: 'dependency-origin-tool',
|
|
|
|
|
description: 'proves AgentLoop dependency origin',
|
|
|
|
|
parameters: {},
|
|
|
|
|
execute: () => Promise.resolve(text('ok')),
|
2026-07-21 03:08:35 +08:00
|
|
|
}))
|
2026-07-12 08:57:05 +08:00
|
|
|
agentCtx.systemPrompt.section({
|
|
|
|
|
name: 'dependency-origin-section',
|
|
|
|
|
order: 1,
|
2026-07-24 19:54:25 +08:00
|
|
|
text: 'factory dependency API',
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}, { inject: ['agents'] }))
|
|
|
|
|
|
|
|
|
|
const handle = await creating
|
|
|
|
|
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(handle.agent))
|
|
|
|
|
expect(assembly.tools.map(tool => tool.name)).toContain('dependency-origin-tool')
|
|
|
|
|
expect(assembly.sections.map(section => section.name)).toContain('dependency-origin-section')
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
await owner.dispose()
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps both entries and the scope live through a reentrant session/created teardown', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
let ownerCtx!: Context
|
|
|
|
|
let creating!: ReturnType<typeof ctx.agents.create>
|
|
|
|
|
const lifecycle: string[] = []
|
|
|
|
|
ctx.on('session/created', (session) => {
|
|
|
|
|
if (session.id !== SessionId('session-created-barrier-s')) return
|
|
|
|
|
lifecycle.push('session-created:dispose')
|
|
|
|
|
disposeCurrentLifecycle(ownerCtx)
|
|
|
|
|
})
|
|
|
|
|
ctx.on('session/created', (session) => {
|
|
|
|
|
if (session.id !== SessionId('session-created-barrier-s')) return
|
2026-07-14 01:59:21 +08:00
|
|
|
const agent = ctx.agents.get(SessionId('session-created-barrier-s'))!
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(ctx.sessions.get(session.id)).toBe(session)
|
|
|
|
|
expect(agent.session).toBe(session)
|
|
|
|
|
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
|
|
|
|
|
lifecycle.push('session-created:observer')
|
|
|
|
|
})
|
|
|
|
|
ctx.on('agent/created', () => void lifecycle.push('agent-created'))
|
|
|
|
|
ctx.on('agent/disposed', () => void lifecycle.push('agent-disposed'))
|
|
|
|
|
ctx.on('session/disposed', (session) => {
|
|
|
|
|
if (session.id === SessionId('session-created-barrier-s')) lifecycle.push('session-disposed')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
|
|
|
|
ownerCtx = inner
|
|
|
|
|
creating = inner.agents.create({
|
|
|
|
|
sessionId: SessionId('session-created-barrier-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
|
|
|
|
}, { inject: ['agents'] }))
|
|
|
|
|
|
2026-07-24 21:18:48 +08:00
|
|
|
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
2026-07-12 08:57:05 +08:00
|
|
|
await owner.dispose()
|
|
|
|
|
expect(lifecycle).toEqual([
|
|
|
|
|
'session-created:dispose',
|
|
|
|
|
'session-created:observer',
|
|
|
|
|
'scope-disposed',
|
2026-07-24 21:18:48 +08:00
|
|
|
'session-disposed',
|
2026-07-12 08:57:05 +08:00
|
|
|
])
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('session-created-barrier-s'))).toBeUndefined()
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined()
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps both entries and the scope live through a reentrant agent/created teardown', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
let ownerCtx!: Context
|
|
|
|
|
let creating!: ReturnType<typeof ctx.agents.create>
|
|
|
|
|
const lifecycle: string[] = []
|
|
|
|
|
ctx.on('session/created', (session) => {
|
|
|
|
|
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created')
|
|
|
|
|
})
|
2026-08-06 12:13:14 +08:00
|
|
|
ctx.on('agent/created', ({ agent }) => {
|
2026-07-14 01:59:21 +08:00
|
|
|
if (agent.id !== SessionId('agent-created-barrier-s')) return
|
2026-07-12 08:57:05 +08:00
|
|
|
lifecycle.push('agent-created:dispose')
|
|
|
|
|
disposeCurrentLifecycle(ownerCtx)
|
|
|
|
|
})
|
2026-08-06 12:13:14 +08:00
|
|
|
ctx.on('agent/created', ({ agent }) => {
|
2026-07-14 01:59:21 +08:00
|
|
|
if (agent.id !== SessionId('agent-created-barrier-s')) return
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(ctx.agents.get(agent.id)).toBe(agent)
|
|
|
|
|
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
|
|
|
|
|
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
|
|
|
|
|
lifecycle.push('agent-created:observer')
|
|
|
|
|
})
|
2026-08-06 12:13:14 +08:00
|
|
|
ctx.on('agent/disposed', ({ agent }) => {
|
2026-07-14 01:59:21 +08:00
|
|
|
if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed')
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
|
|
|
|
ctx.on('session/disposed', (session) => {
|
|
|
|
|
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
|
|
|
|
ownerCtx = inner
|
|
|
|
|
creating = inner.agents.create({
|
|
|
|
|
sessionId: SessionId('agent-created-barrier-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
|
|
|
|
}, { inject: ['agents'] }))
|
|
|
|
|
|
2026-07-24 21:18:48 +08:00
|
|
|
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
2026-07-12 08:57:05 +08:00
|
|
|
await owner.dispose()
|
|
|
|
|
expect(lifecycle).toEqual([
|
|
|
|
|
'session-created',
|
|
|
|
|
'agent-created:dispose',
|
|
|
|
|
'agent-created:observer',
|
2026-07-24 21:18:48 +08:00
|
|
|
'scope-disposed',
|
2026-07-12 08:57:05 +08:00
|
|
|
'agent-disposed',
|
|
|
|
|
'session-disposed',
|
|
|
|
|
])
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rechecks caller liveness after creation listeners before unlocking the driver', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const starts: string[] = []
|
|
|
|
|
let ownerCtx!: Context
|
|
|
|
|
let creating!: ReturnType<typeof ctx.agents.create>
|
2026-08-06 12:13:14 +08:00
|
|
|
ctx.on('agent/session-start', ({ agent }) => void starts.push(agent.id))
|
|
|
|
|
ctx.on('agent/created', ({ agent }) => {
|
2026-07-24 21:18:48 +08:00
|
|
|
if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx)
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
|
|
|
|
ownerCtx = inner
|
|
|
|
|
creating = inner.agents.create({
|
|
|
|
|
sessionId: SessionId('listener-dispose-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
|
|
|
|
}, { inject: ['agents'] }))
|
|
|
|
|
|
|
|
|
|
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
|
|
|
|
await owner.dispose()
|
|
|
|
|
expect(starts).toEqual([])
|
2026-07-24 21:18:48 +08:00
|
|
|
expect(ctx.agents.get(SessionId('listener-dispose-s')) === undefined).toBe(true)
|
|
|
|
|
expect(ctx.sessions.get(SessionId('listener-dispose-s')) === undefined).toBe(true)
|
2026-07-12 08:57:05 +08:00
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rechecks caller liveness after session-start before starting the driver', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
let ownerCtx!: Context
|
|
|
|
|
let creating!: ReturnType<typeof ctx.agents.create>
|
2026-07-14 02:32:35 +08:00
|
|
|
let announced!: Agent
|
2026-07-12 08:57:05 +08:00
|
|
|
const statuses: string[] = []
|
|
|
|
|
let scopeDisposed = false
|
|
|
|
|
let observerSawLive = false
|
2026-08-06 12:13:14 +08:00
|
|
|
ctx.on('agent/status', ({ agent, status }) => {
|
2026-07-14 01:59:21 +08:00
|
|
|
if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status)
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
2026-08-06 12:13:14 +08:00
|
|
|
ctx.on('agent/session-start', ({ agent }) => {
|
2026-07-14 01:59:21 +08:00
|
|
|
if (agent.id !== SessionId('session-start-dispose-s')) return
|
2026-07-14 02:32:35 +08:00
|
|
|
announced = agent
|
2026-07-12 08:57:05 +08:00
|
|
|
disposeCurrentLifecycle(ownerCtx)
|
|
|
|
|
})
|
2026-08-06 12:13:14 +08:00
|
|
|
ctx.on('agent/session-start', ({ agent }) => {
|
2026-07-14 01:59:21 +08:00
|
|
|
if (agent.id !== SessionId('session-start-dispose-s')) return
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(ctx.agents.get(agent.id)).toBe(agent)
|
|
|
|
|
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
|
|
|
|
|
agent.ctx.effect(() => () => { scopeDisposed = true })
|
|
|
|
|
observerSawLive = true
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
|
|
|
|
ownerCtx = inner
|
|
|
|
|
creating = inner.agents.create({
|
|
|
|
|
sessionId: SessionId('session-start-dispose-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
|
|
|
|
}, { inject: ['agents'] }))
|
|
|
|
|
|
2026-07-24 21:18:48 +08:00
|
|
|
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
2026-07-12 08:57:05 +08:00
|
|
|
await owner.dispose()
|
2026-07-24 21:18:48 +08:00
|
|
|
expect(announced.status).toBe('idle')
|
|
|
|
|
expect(statuses).toEqual([])
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(observerSawLive).toBe(true)
|
|
|
|
|
expect(scopeDisposed).toBe(true)
|
|
|
|
|
expect(announced.session.events).toEqual([])
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined()
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined()
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 22:55:26 +08:00
|
|
|
it('a rejecting setup publishes nothing and unwinds the unpublished scope', async () => {
|
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
|
|
|
const ctx = await harness()
|
2026-07-11 22:55:26 +08:00
|
|
|
const published: string[] = []
|
|
|
|
|
ctx.on('session/created', () => void published.push('session/created'))
|
|
|
|
|
ctx.on('agent/created', () => void published.push('agent/created'))
|
|
|
|
|
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
|
|
|
|
await expect(ctx.agents.create({
|
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
|
|
|
sessionId: SessionId('bad-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-11 22:55:26 +08:00
|
|
|
setup: async () => {
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
throw new Error('boom setup')
|
|
|
|
|
},
|
|
|
|
|
})).rejects.toThrow('boom setup')
|
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
|
|
|
|
|
|
|
|
// Nothing leaked: no agent, no session, and the ids are reusable.
|
2026-07-11 22:55:26 +08:00
|
|
|
expect(published).toEqual([])
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined()
|
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
|
|
|
expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
|
2026-07-18 12:21:15 +08:00
|
|
|
const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } })
|
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
|
|
|
await retry.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-14 01:59:21 +08:00
|
|
|
it('rejects an exotic durable seed before publishing either object', async () => {
|
2026-07-12 03:51:55 +08:00
|
|
|
const ctx = await harness()
|
|
|
|
|
const published: string[] = []
|
|
|
|
|
ctx.on('session/created', () => { published.push('session') })
|
|
|
|
|
ctx.on('agent/created', () => { published.push('agent') })
|
|
|
|
|
class ExoticData { readonly value = 'not durable JSON' }
|
|
|
|
|
const seed = [{
|
|
|
|
|
seq: 0,
|
|
|
|
|
type: 'test/exotic-seed',
|
|
|
|
|
data: new ExoticData(),
|
|
|
|
|
}] as unknown as SessionEvent[]
|
|
|
|
|
|
|
|
|
|
await expect(ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('exotic-seed-session'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 03:51:55 +08:00
|
|
|
seed,
|
|
|
|
|
})).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/)
|
|
|
|
|
|
|
|
|
|
expect(published).toEqual([])
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('exotic-seed-session'))).toBeUndefined()
|
2026-07-12 03:51:55 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined()
|
|
|
|
|
const retry = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('exotic-seed-session'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 03:51:55 +08:00
|
|
|
})
|
|
|
|
|
await retry.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
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
|
|
|
it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
let boom = true
|
2026-07-11 22:55:26 +08:00
|
|
|
const disposed: string[] = []
|
2026-08-06 12:13:14 +08:00
|
|
|
ctx.on('agent/disposed', ({ agent }) => void disposed.push(agent.id))
|
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.on('session/created', () => {
|
|
|
|
|
if (boom) { boom = false; throw new Error('boom created') }
|
|
|
|
|
})
|
2026-07-11 22:55:26 +08:00
|
|
|
await expect(ctx.agents.create({
|
2026-07-18 12:21:15 +08:00
|
|
|
sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-11 22:55:26 +08:00
|
|
|
})).rejects.toThrow('boom created')
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined()
|
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
|
|
|
expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
|
2026-07-11 22:55:26 +08:00
|
|
|
expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge
|
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
|
|
|
// The rollback also disposed the scope fiber: re-creating works cleanly.
|
2026-07-18 12:21:15 +08:00
|
|
|
const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } })
|
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
|
|
|
expect(scopeOf(retry.agent.ctx)).toBe(retry.agent)
|
|
|
|
|
await retry.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 05:13:17 +08:00
|
|
|
it('pairs session and agent announcements when agent creation aborts publication', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const lifecycle: string[] = []
|
|
|
|
|
ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) })
|
|
|
|
|
ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${session.id}`) })
|
2026-08-06 12:13:14 +08:00
|
|
|
ctx.on('agent/created', ({ agent }) => {
|
2026-07-12 05:13:17 +08:00
|
|
|
lifecycle.push(`agent-created:${agent.id}`)
|
|
|
|
|
throw new Error('agent observer failed')
|
|
|
|
|
})
|
2026-08-06 12:13:14 +08:00
|
|
|
ctx.on('agent/disposed', ({ agent }) => { lifecycle.push(`agent-disposed:${agent.id}`) })
|
2026-07-12 05:13:17 +08:00
|
|
|
|
|
|
|
|
await expect(ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('partial-session'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 05:13:17 +08:00
|
|
|
})).rejects.toThrow('agent observer failed')
|
|
|
|
|
|
|
|
|
|
expect(lifecycle).toEqual([
|
|
|
|
|
'session-created:partial-session',
|
2026-07-14 01:59:21 +08:00
|
|
|
'agent-created:partial-session',
|
|
|
|
|
'agent-disposed:partial-session',
|
2026-07-12 05:13:17 +08:00
|
|
|
'session-disposed:partial-session',
|
|
|
|
|
])
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('partial-session'))).toBeUndefined()
|
2026-07-12 05:13:17 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 22:55:26 +08:00
|
|
|
it('the synchronous config helper rolls back when publication throws', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const sessionsBefore = ctx.sessions.list().length
|
|
|
|
|
let boom = true
|
|
|
|
|
ctx.on('session/created', () => {
|
|
|
|
|
if (boom) {
|
|
|
|
|
boom = false
|
|
|
|
|
throw new Error('config publish failed')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-18 12:21:15 +08:00
|
|
|
expect(() => ctx.agentLoop.create(SessionId('config-bad'), { provider: 'mock', model: 'mock' }))
|
2026-07-11 22:55:26 +08:00
|
|
|
.toThrow('config publish failed')
|
2026-07-24 21:18:48 +08:00
|
|
|
await expect.poll(() => ctx.agents.get(SessionId('config-bad')) === undefined).toBe(true)
|
|
|
|
|
await expect.poll(() => ctx.sessions.list().length).toBe(sessionsBefore)
|
2026-07-11 22:55:26 +08:00
|
|
|
})
|
|
|
|
|
|
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
|
|
|
it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
|
|
|
|
|
const ctx = await harness()
|
2026-07-18 12:21:15 +08:00
|
|
|
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } })
|
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
|
|
|
await handle.dispose()
|
|
|
|
|
expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('agentEvents fuses carrier and subject for custom drivers', async () => {
|
|
|
|
|
const ctx = await harness()
|
2026-07-18 12:21:15 +08:00
|
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' })
|
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
|
|
|
const heard: string[] = []
|
2026-08-06 12:13:14 +08:00
|
|
|
agent.ctx.on('agent/error', ({ agent: subject, turn }) => void heard.push(`${subject.id}:${turn}`))
|
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
|
|
|
|
2026-08-06 12:13:14 +08:00
|
|
|
agentEvents(ctx, other).emit('agent/error', { turn: 1, step: 0, error: new Error('not for a1') })
|
|
|
|
|
agentEvents(ctx, agent).emit('agent/error', { turn: 2, step: 0, error: new Error('for a1') })
|
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
|
|
|
expect(heard).toEqual(['a1:2'])
|
|
|
|
|
})
|
2026-07-09 04:48:52 +08:00
|
|
|
|
|
|
|
|
it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => {
|
|
|
|
|
const ctx = await harness()
|
2026-07-11 22:55:26 +08:00
|
|
|
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
|
|
|
|
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
2026-07-18 12:21:15 +08:00
|
|
|
handle = await inner.agents.create({ sessionId: SessionId('o1-s'), agentOptions: { provider: 'mock', model: 'mock' } })
|
2026-07-09 04:48:52 +08:00
|
|
|
}, { inject: ['agents'] }))
|
|
|
|
|
const { agent } = handle
|
|
|
|
|
|
|
|
|
|
const order: string[] = []
|
|
|
|
|
ctx.on('session/event', (_s, event) => {
|
|
|
|
|
if (event.type === 'turn/end') order.push('turn-end')
|
|
|
|
|
})
|
|
|
|
|
ctx.on('agent/disposed', () => {
|
2026-07-14 01:59:21 +08:00
|
|
|
order.push(`disposed(listed=${ctx.agents.get(SessionId('o1-s')) !== undefined})`)
|
2026-07-09 04:48:52 +08:00
|
|
|
order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-14 16:21:41 +08:00
|
|
|
// Open a turn so disposal must drain real work before registry removal.
|
|
|
|
|
// Waiting for turn/start avoids pre-step disposal dropping the queued prompt
|
|
|
|
|
// before a turn opens.
|
2026-07-09 04:48:52 +08:00
|
|
|
const turnOpen = new Promise<void>((resolve) => {
|
|
|
|
|
const off = ctx.on('session/event', (_s, event) => {
|
|
|
|
|
if (event.type === 'turn/start') { off(); resolve() }
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-07-28 13:55:59 +08:00
|
|
|
agent.followup(createUserMessage({ content: text('work'), source: { kind: 'user' } }))
|
2026-07-09 04:48:52 +08:00
|
|
|
await turnOpen
|
|
|
|
|
await owner.dispose()
|
2026-07-24 21:18:48 +08:00
|
|
|
expect(order).toEqual([
|
|
|
|
|
'turn-end',
|
|
|
|
|
'disposed(listed=false)',
|
|
|
|
|
'session-still-stored=true',
|
|
|
|
|
])
|
2026-07-09 04:48:52 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('o1-s'))).toBeUndefined()
|
|
|
|
|
})
|
2026-07-09 03:58:15 +08:00
|
|
|
|
|
|
|
|
it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => {
|
|
|
|
|
const ctx = await harness()
|
2026-07-11 22:55:26 +08:00
|
|
|
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
|
|
|
|
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
2026-07-18 12:21:15 +08:00
|
|
|
handle = await inner.agents.create({ sessionId: SessionId('h1-s'), agentOptions: { provider: 'mock', model: 'mock' } })
|
2026-07-09 03:58:15 +08:00
|
|
|
}, { inject: ['agents'] }))
|
|
|
|
|
|
|
|
|
|
const teardownDone: string[] = []
|
|
|
|
|
ctx.on('agent/disposed', () => void teardownDone.push('unregistered'))
|
|
|
|
|
|
|
|
|
|
// Owner unload begins FIRST (invokes the raw cordis wrapper)…
|
|
|
|
|
const unload = owner.dispose()
|
|
|
|
|
// …and a concurrent handle.dispose() must not resolve before the chain
|
|
|
|
|
// actually finished (the raw wrapper returns undefined on a repeat call).
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
expect(teardownDone).toContain('unregistered')
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('h1-s'))).toBeUndefined()
|
2026-07-09 03:58:15 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined()
|
|
|
|
|
await unload
|
|
|
|
|
})
|
2026-07-12 00:31:46 +08:00
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
it('successful handle disposal retires its caller ownership effect', async () => {
|
2026-07-12 08:57:05 +08:00
|
|
|
const ctx = await harness()
|
2026-07-14 01:59:21 +08:00
|
|
|
const sessionId = SessionId('retired-owner-effect')
|
2026-07-12 08:57:05 +08:00
|
|
|
const handle = await ctx.agents.create({
|
2026-07-14 01:59:21 +08:00
|
|
|
sessionId,
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 08:57:05 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-24 21:18:48 +08:00
|
|
|
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.lifecycle(${sessionId})`)
|
2026-07-12 08:57:05 +08:00
|
|
|
await handle.dispose()
|
2026-07-24 21:18:48 +08:00
|
|
|
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.lifecycle(${sessionId})`)).toEqual([])
|
2026-07-12 08:57:05 +08:00
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('owner unload after handle-first teardown follows the same in-flight boundary', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const gate = Promise.withResolvers<undefined>()
|
|
|
|
|
const cleanupStarted = Promise.withResolvers<undefined>()
|
|
|
|
|
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
|
|
|
|
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
|
|
|
|
handle = await inner.agents.create({
|
|
|
|
|
sessionId: SessionId('manual-first-s'),
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 08:57:05 +08:00
|
|
|
setup(agentCtx) {
|
|
|
|
|
agentCtx.effect(() => async () => {
|
|
|
|
|
cleanupStarted.resolve(undefined)
|
|
|
|
|
await gate.promise
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}, { inject: ['agents'] }))
|
|
|
|
|
|
|
|
|
|
const disposing = handle.dispose()
|
|
|
|
|
await cleanupStarted.promise
|
|
|
|
|
let ownerSettled = false
|
|
|
|
|
const unloading = owner.dispose().then(() => { ownerSettled = true })
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
expect(ownerSettled).toBe(false)
|
|
|
|
|
gate.resolve(undefined)
|
|
|
|
|
await Promise.all([disposing, unloading])
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(SessionId('manual-first-s'))).toBeUndefined()
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined()
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-24 21:18:48 +08:00
|
|
|
it('reopens ids after the prior private scope finishes quiescing', async () => {
|
2026-07-12 08:57:05 +08:00
|
|
|
const ctx = await harness()
|
|
|
|
|
const gate = Promise.withResolvers<undefined>()
|
|
|
|
|
const cleanupStarted = Promise.withResolvers<undefined>()
|
2026-07-14 01:59:21 +08:00
|
|
|
const sessionId = SessionId('quiescent-reuse')
|
2026-07-12 08:57:05 +08:00
|
|
|
const first = await ctx.agents.create({
|
|
|
|
|
sessionId,
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
2026-07-12 08:57:05 +08:00
|
|
|
setup(agentCtx) {
|
|
|
|
|
agentCtx.effect(() => async () => {
|
|
|
|
|
cleanupStarted.resolve(undefined)
|
|
|
|
|
await gate.promise
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const disposing = first.dispose()
|
2026-07-24 21:18:48 +08:00
|
|
|
await cleanupStarted.promise
|
|
|
|
|
expect(ctx.agents.get(sessionId)).toBe(first.agent)
|
|
|
|
|
expect(ctx.sessions.get(sessionId)).toBe(first.agent.session)
|
|
|
|
|
gate.resolve(undefined)
|
|
|
|
|
await disposing
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
2026-07-12 08:57:05 +08:00
|
|
|
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
2026-07-18 12:21:15 +08:00
|
|
|
const replacement = await ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
2026-07-14 01:59:21 +08:00
|
|
|
expect(ctx.agents.get(sessionId)).toBe(replacement.agent)
|
2026-07-12 22:36:04 +08:00
|
|
|
expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session)
|
2026-07-12 08:57:05 +08:00
|
|
|
await replacement.dispose()
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-26 20:50:02 +08:00
|
|
|
it('drains a run re-entered by cancel\'s own idle transition before removing the scope', async () => {
|
2026-08-13 00:36:22 +08:00
|
|
|
// Automation shaped like goal-round-driver: the running→idle transition that
|
2026-07-26 20:50:02 +08:00
|
|
|
// disposal's cancel produces immediately queues a follow-up prompt. The
|
|
|
|
|
// teardown must drain that replacement run to true quiescence instead of
|
|
|
|
|
// awaiting only the first captured done and unwinding under a live run.
|
|
|
|
|
const adapter = new MockAdapter([textResponse('one'), textResponse('never awaited')])
|
|
|
|
|
const ctx = await harness(adapter)
|
|
|
|
|
const handle = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('drain-reentered-run'),
|
|
|
|
|
agentOptions: { provider: 'mock', model: 'mock' },
|
|
|
|
|
})
|
|
|
|
|
const agent = handle.agent
|
|
|
|
|
let reentered = false
|
2026-08-06 12:13:14 +08:00
|
|
|
ctx.on('agent/status', ({ agent: subject, status }) => {
|
2026-07-26 20:50:02 +08:00
|
|
|
if (subject !== agent || status !== 'idle' || reentered) return
|
|
|
|
|
reentered = true
|
2026-07-28 13:55:59 +08:00
|
|
|
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'reentrant' }], source: { kind: 'user' } }))
|
2026-07-26 20:50:02 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-28 13:55:59 +08:00
|
|
|
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
2026-07-26 20:50:02 +08:00
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
expect(reentered).toBe(true)
|
|
|
|
|
|
2026-07-31 19:21:16 +08:00
|
|
|
// Idle again: the reentrant batch was already claimed and settled (its
|
2026-07-26 20:50:02 +08:00
|
|
|
// prompt was blocked by nothing, so it ran) — arm a SECOND reentry that
|
|
|
|
|
// fires from the disposal cancel's idle transition itself.
|
|
|
|
|
reentered = false
|
|
|
|
|
await handle.dispose()
|
|
|
|
|
|
|
|
|
|
// The reentrant run either never started or was drained: the registries
|
|
|
|
|
// are empty and nothing still drives the detached session.
|
|
|
|
|
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
|
|
|
|
expect(ctx.sessions.get(agent.id)).toBeUndefined()
|
|
|
|
|
const eventsAfter = agent.session.events.length
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 30))
|
|
|
|
|
expect(agent.session.events.length).toBe(eventsAfter)
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
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
|
|
|
})
|