refactor(core): fold initiator scope into agents

This commit is contained in:
Tianyi Cui
2026-07-19 13:30:45 +08:00
parent 536cb6f985
commit c23214be56
79 changed files with 1883 additions and 1003 deletions

View File

@@ -8,12 +8,11 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
| `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` |
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
| `agent-execution/` | Process-local ambient Agent identity for asynchronous driver work | `ctx.agentExecution` |
| `agent/` | Agent interface, live registry, process-local initiator scope, `agent/*` event vocabulary | `ctx.agents` |
| `agent-loop/` | Concrete plugin implementing the public `Agent` contract and owning the loop driver | `ctx.agentLoop` |
`scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle.
`agent-execution` is mandatory control infrastructure for concrete loops and deep process-local consumers. `agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop. Extension plugins depend on `agent` and, when they need ambient identity, `agent-execution`; they never depend on `agent-loop` directly, so the loop stays swappable.
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop. It runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent`, including when they need the initiating Agent, and never on `agent-loop` directly, so the loop stays swappable.
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + agent-execution + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.

View File

@@ -1,23 +0,0 @@
# dsh-agent-execution
Process-local ambient Agent identity for asynchronous work initiated by a concrete agent driver. The default export, `AgentExecutionProvider`, installs the mandatory `ctx.agentExecution` service; [`dsh-agent-loop`](../agent-loop/README.md) establishes one boundary around each driver's complete lifetime.
## Service: `AgentExecutionService` (ctx key: `agentExecution`)
- `current()` returns the inherited `AgentExecution` or `undefined` outside a driver and inside an explicit clearing boundary.
- `require()` returns the inherited execution or throws `no agent execution context is active`.
- `run(execution, operation)` returns the exact synchronous value or Promise from `operation`. Passing `undefined` establishes a real boundary that hides an inherited Agent.
The store contains only `{ readonly agent: Agent }`. The `Session` remains available through `agent.session`; turn, step, `signal`, `cwd`, sandbox, authorization, and other capability state remain with their explicit owners. Ambient presence identifies the initiator but does not prove that the Agent is live or that an operation is authorized.
## Lifetime and detached work
Provider teardown rejects new `run()` boundaries, removes the service so injected dependents drain, waits for returned Promise boundaries, then disables its `AsyncLocalStorage`. In-flight code retaining the service can call `current()` and `require()` while it drains; after disposal, all three methods throw `agent execution service is disposed`.
Async resources created inside `run()` inherit its Agent even if `operation` returns before they settle, but provider teardown waits only for the Promise returned by `operation`. The owning seam must stop unreturned work explicitly. Unrelated timers, queues, and deployment infrastructure start under `run(undefined, operation)` and own an explicit stop; queue, worker, process, and wire boundaries serialize any identity they need instead of relying on ALS propagation.
## Known Limitations and Deferred Work
- **Process-local only** — ALS does not cross workers, child processes, HTTP, durable queues, or restarts; each boundary materializes a typed identity explicitly.
- **Agent identity only** — turn, step, signal, cwd, sandbox, and authorization stay outside the frame until a concrete cross-cutting consumer justifies a separate design.
- **Ambient references may outlive liveness** — consumers still check `agent.status`, their explicit signal, and the owning capability contract before lifecycle-sensitive work.

View File

@@ -1,31 +0,0 @@
{
"name": "@deepseek-ai/dsh-agent-execution",
"description": "Process-local ambient Agent context for asynchronous driver work",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,140 +0,0 @@
/**
* Process-local Agent execution context backed by Node AsyncLocalStorage.
*
* @module @deepseek-ai/dsh-agent-execution
*/
import type { Context } from 'cordis'
import { AsyncLocalStorage } from 'node:async_hooks'
import { isPromise } from 'node:util/types'
import type { AgentExecution } from './types.ts'
export type { AgentExecution } from './types.ts'
const NO_ACTIVE_EXECUTION = 'no agent execution context is active'
const DISPOSED_SERVICE = 'agent execution service is disposed'
/** Ambient Agent identity within one process-local asynchronous chain. */
export interface AgentExecutionService {
/**
* Read the active execution without requiring one.
* @returns the inherited execution, or `undefined` outside/inside a cleared boundary.
* @throws when this service instance has been disposed.
*/
current(): AgentExecution | undefined
/**
* Read the active execution and fail when no boundary is active.
* @returns the inherited execution.
* @throws when no execution is active or this service instance has been disposed.
*/
require(): AgentExecution
/**
* Run an operation inside an execution boundary. Passing `undefined` clears
* an inherited execution; the exact synchronous value or Promise is returned.
* @param execution - execution to inherit, or `undefined` for a clearing boundary.
* @param operation - synchronous or asynchronous operation to invoke.
* @returns the exact value returned by `operation`.
* @throws when this service is closing/disposed, or when `operation` throws.
*/
run<T>(execution: AgentExecution | undefined, operation: () => T): T
}
declare module 'cordis' {
interface Context {
agentExecution: AgentExecutionService
}
}
/** One provider-owned ALS instance with quiescent shutdown. */
class DefaultAgentExecutionService implements AgentExecutionService {
private readonly storage = new AsyncLocalStorage<AgentExecution | undefined>()
private state: 'active' | 'closing' | 'disposed' = 'active'
private activeRuns = 0
private drainWaiter: PromiseWithResolvers<void> | undefined
private disposalTask: Promise<void> | undefined
current(): AgentExecution | undefined {
this.assertReadable()
return this.storage.getStore()
}
require(): AgentExecution {
const execution = this.current()
if (execution === undefined) throw new Error(NO_ACTIVE_EXECUTION)
return execution
}
run<T>(execution: AgentExecution | undefined, operation: () => T): T {
if (this.state !== 'active') throw new Error(DISPOSED_SERVICE)
this.activeRuns += 1
let result: T
try {
result = this.storage.run(execution, operation)
} catch (error: unknown) {
this.releaseRun()
throw error
}
if (isPromise(result)) {
void result.then(
() => { this.releaseRun() },
() => { this.releaseRun() },
)
} else {
this.releaseRun()
}
return result
}
/** Reject new boundaries while existing continuations remain readable. */
close(): void {
if (this.state === 'active') this.state = 'closing'
}
/** Wait for every returned Promise boundary, then invalidate retained references. */
dispose(): Promise<void> {
return (this.disposalTask ??= (async () => {
this.close()
if (this.activeRuns !== 0) {
this.drainWaiter ??= Promise.withResolvers<void>()
await this.drainWaiter.promise
}
this.state = 'disposed'
this.storage.disable()
})())
}
private assertReadable(): void {
if (this.state === 'disposed') throw new Error(DISPOSED_SERVICE)
}
private releaseRun(): void {
this.activeRuns -= 1
if (this.activeRuns !== 0) return
this.drainWaiter?.resolve()
this.drainWaiter = undefined
}
}
/** Cordis provider for the mandatory `ctx.agentExecution` service. */
export class AgentExecutionProvider {
private readonly service = new DefaultAgentExecutionService()
/**
* Install one isolated execution service and its ordered lifecycle.
* @param ctx - provider-owning Cordis context.
*/
constructor(ctx: Context) {
const service = this.service
ctx.effect(function* () {
// First yielded, disposed last: invalidate ALS only after dependents and active runs drain.
yield () => service.dispose()
yield ctx.provide('agentExecution', service)
// Last yielded, disposed first: prevent a teardown race from opening another boundary.
yield () => { service.close() }
}, 'agentExecution.lifecycle()')
}
}
export default AgentExecutionProvider

View File

@@ -1,12 +0,0 @@
/**
* Public Agent execution-context types.
*
* @module @deepseek-ai/dsh-agent-execution/types
*/
import type { Agent } from '@deepseek-ai/dsh-agent'
/** The exact Agent associated with one asynchronous execution chain. */
export interface AgentExecution {
readonly agent: Agent
}

View File

@@ -1,163 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { runInNewContext } from 'node:vm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import type { AgentExecution, AgentExecutionService } from '@deepseek-ai/dsh-agent-execution'
import { SessionId } from '@deepseek-ai/dsh-session'
function execution(id: string): AgentExecution {
return { agent: { id: SessionId(id) } as Agent }
}
async function harness(): Promise<{
ctx: Context
service: AgentExecutionService
dispose: () => Promise<void>
}> {
const ctx = new Context()
const fiber = await ctx.plugin(AgentExecutionProvider)
return {
ctx,
service: ctx.agentExecution,
dispose: fiber.dispose,
}
}
describe('AgentExecutionProvider', () => {
it('reports an absent boundary and requires an active execution', async () => {
const { service, dispose } = await harness()
expect(service.current()).toBeUndefined()
expect(() => service.require()).toThrow('no agent execution context is active')
await dispose()
})
it('preserves exact synchronous and Promise return identities across await', async () => {
const { service, dispose } = await harness()
const active = execution('identity')
const value = { result: true }
expect(service.run(active, () => {
expect(service.require()).toBe(active)
return value
})).toBe(value)
const promise = service.run(active, async () => {
expect(service.require()).toBe(active)
await Promise.resolve()
expect(service.require()).toBe(active)
return value
})
expect(service.run(active, () => promise)).toBe(promise)
await expect(promise).resolves.toBe(value)
expect(service.current()).toBeUndefined()
await dispose()
})
it('isolates overlapping executions', async () => {
const { service, dispose } = await harness()
const a = execution('a')
const b = execution('b')
const bothStarted = Promise.withResolvers<boolean>()
const release = Promise.withResolvers<boolean>()
let starts = 0
const run = (active: AgentExecution): Promise<void> => service.run(active, async () => {
expect(service.require()).toBe(active)
starts += 1
if (starts === 2) bothStarted.resolve(true)
await release.promise
expect(service.require()).toBe(active)
})
const pending = [run(a), run(b)]
await bothStarted.promise
expect(service.current()).toBeUndefined()
release.resolve(true)
await Promise.all(pending)
await dispose()
})
it('restores nested and explicitly cleared boundaries', async () => {
const { service, dispose } = await harness()
const parent = execution('parent')
const child = execution('child')
service.run(parent, () => {
expect(service.require()).toBe(parent)
service.run(child, () => { expect(service.require()).toBe(child) })
expect(service.require()).toBe(parent)
service.run(undefined, () => {
expect(service.current()).toBeUndefined()
expect(() => service.require()).toThrow('no agent execution context is active')
})
expect(service.require()).toBe(parent)
})
expect(service.current()).toBeUndefined()
await dispose()
})
it('restores context after synchronous throws and rejected operations', async () => {
const { service, dispose } = await harness()
const parent = execution('parent')
const child = execution('child')
const syncError = new Error('sync failure')
const asyncError = new Error('async failure')
service.run(parent, () => {
expect(() => service.run(child, () => { throw syncError })).toThrow(syncError)
expect(service.require()).toBe(parent)
})
await expect(service.run(child, async () => {
await Promise.resolve()
throw asyncError
})).rejects.toBe(asyncError)
expect(service.current()).toBeUndefined()
await dispose()
})
it('stops new boundaries, drains active Promises, and invalidates retained references', async () => {
const { ctx, service, dispose } = await harness()
const active = execution('draining')
const release = Promise.withResolvers<boolean>()
const pending = service.run(active, async () => {
await release.promise
expect(service.require()).toBe(active)
})
let disposed = false
const disposal = dispose().then(() => { disposed = true })
await Promise.resolve()
expect(() => service.run(active, () => 1)).toThrow('agent execution service is disposed')
expect(disposed).toBe(false)
expect(ctx.get('agentExecution')).toBeUndefined()
release.resolve(true)
await pending
await disposal
expect(() => service.current()).toThrow('agent execution service is disposed')
expect(() => service.require()).toThrow('agent execution service is disposed')
})
it('drains cross-realm Promise boundaries before disposal', async () => {
const { service, dispose } = await harness()
const active = execution('cross-realm')
const release = Promise.withResolvers<boolean>()
const operation = runInNewContext(
'(async () => { await release; inspect() })',
{
release: release.promise,
inspect: () => { expect(service.require()).toBe(active) },
},
) as () => Promise<void>
const pending = service.run(active, operation)
expect(pending).not.toBeInstanceOf(Promise)
let disposed = false
const disposal = dispose().then(() => { disposed = true })
await Promise.resolve()
expect(disposed).toBe(false)
release.resolve(true)
await pending
await disposal
expect(disposed).toBe(true)
})
})

View File

@@ -1,21 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
}
]
}

View File

@@ -25,7 +25,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
### Injected services
`agents`, `agentExecution`, `sessions`, `llm`, `tools`, `systemPrompt` — all six interface services. The loop cannot activate without `agentExecution`; the default bundle loads its provider before the loop.
`agents`, `sessions`, `llm`, `tools`, `systemPrompt` — all five interface services.
### Configuration (schemastery)
@@ -50,7 +50,7 @@ The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publicati
### Loop lifecycle (`loop.ts`)
The driver owns one agent for its lifetime and runs inside `ctx.agentExecution.run({ agent }, ...)`, so process-local asynchronous continuations can recover the initiating Agent. Creation, persistence load, and unpublished setup stay outside the driver boundary; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. The [execution-context package](../agent-execution/README.md) owns propagation, teardown, and detached-work rules.
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`, so process-local asynchronous continuations can recover the initiating Agent. Creation, persistence load, and unpublished setup stay outside the driver boundary; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules.
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.

View File

@@ -22,7 +22,6 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-execution": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -36,7 +35,6 @@
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-execution": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",

View File

@@ -387,7 +387,7 @@ export class ReactLoopAgent implements Agent {
[startDriver](): void {
if (this._status === 'disposed') return
this.driverStarted = true
this.done = this.loopCtx.agentExecution.run({ agent: this }, () => runLoop(this.loopCtx, this, {
this.done = this.loopCtx.agents.withInitiator(this, () => runLoop(this.loopCtx, this, {
inbox: this.#inbox,
maxParallelToolCalls: this.maxParallelToolCalls,
setStatus: (status) => { this.setStatus(status) },

View File

@@ -11,7 +11,6 @@ import z from 'schemastery'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-execution'
import type {
Agent,
AgentFactory,
@@ -406,7 +405,7 @@ function validateConfiguredAgents(agents: Config['agents']): void {
/** Concrete agent factory and driver service. */
export class AgentLoop extends Service implements AgentFactory {
static inject = ['agents', 'agentExecution', 'sessions', 'llm', 'tools', 'systemPrompt']
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
/** Runtime schema for declarative agents. */
static Config = z.object({

View File

@@ -1,8 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context, FiberState, type Fiber } from 'cordis'
import { Context, type Fiber } from 'cordis'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import type { AgentExecutionService } from '@deepseek-ai/dsh-agent-execution'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
@@ -13,7 +11,7 @@ import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
interface Harness {
ctx: Context
providerFiber: Fiber
agentsFiber: Fiber
loopFiber: Fiber
}
@@ -23,11 +21,10 @@ async function harness(adapter: LlmAdapter): Promise<Harness> {
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
const providerFiber = await ctx.plugin(AgentExecutionProvider)
const agentsFiber = await ctx.plugin(AgentRegistry)
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return { ctx, providerFiber, loopFiber }
return { ctx, agentsFiber, loopFiber }
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
@@ -56,12 +53,12 @@ class OverlapAdapter extends LlmAdapter {
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const before = this.ctx.agentExecution.require().agent
const before = this.ctx.agents.requireInitiator()
this.starts += 1
if (this.starts === 2) this.bothStarted.resolve(true)
await this.bothStarted.promise
await Promise.resolve()
const after = this.ctx.agentExecution.require().agent
const after = this.ctx.agents.requireInitiator()
this.observations.push({ sessionId: options.sessionId, before, after })
yield* textResponse('done')
}
@@ -71,12 +68,12 @@ class OverlapAdapter extends LlmAdapter {
class TestCapabilityTransport {
readonly requests: { path: string; headers: Record<string, string> }[] = []
constructor(private readonly execution: AgentExecutionService) {}
constructor(private readonly agents: AgentRegistry) {}
async request(path: string): Promise<Record<string, string>> {
await Promise.resolve()
const headers = {
'X-Harness-Session-Id': this.execution.require().agent.session.id,
'X-Harness-Session-Id': this.agents.requireInitiator().session.id,
}
this.requests.push({ path, headers })
return headers
@@ -89,11 +86,11 @@ class ReloadAdapter extends LlmAdapter {
firstAgentDuringAbort: Agent | undefined
laterAgent: Agent | undefined
calls = 0
execution: AgentExecutionService | undefined
agents: AgentRegistry | undefined
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const execution = this.execution
if (execution === undefined) throw new Error('execution service missing')
const agents = this.agents
if (agents === undefined) throw new Error('agent service missing')
this.calls += 1
if (this.calls === 1) {
this.firstStarted.resolve(true)
@@ -105,18 +102,18 @@ class ReloadAdapter extends LlmAdapter {
})
} catch (error: unknown) {
await Promise.resolve()
this.firstAgentDuringAbort = execution.require().agent
this.firstAgentDuringAbort = agents.requireInitiator()
throw error
}
return
}
await Promise.resolve()
this.laterAgent = execution.require().agent
this.laterAgent = agents.requireInitiator()
yield* textResponse('reloaded')
}
}
describe('AgentLoop execution context', () => {
describe('AgentLoop initiator scope', () => {
it('keeps overlapping driver continuations bound to their exact Agents', async () => {
const ctx = new Context()
const adapter = new OverlapAdapter(ctx)
@@ -125,7 +122,6 @@ describe('AgentLoop execution context', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -142,7 +138,7 @@ describe('AgentLoop execution context', () => {
{ sessionId: a.session.id, before: a, after: a },
{ sessionId: b.session.id, before: b, after: b },
]))
expect(ctx.agentExecution.current()).toBeUndefined()
expect(ctx.agents.currentInitiator()).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -170,7 +166,7 @@ describe('AgentLoop execution context', () => {
sessionId: SessionId('child-session'),
agentOptions: { provider: 'mock', model: 'mock' },
setup: (agentCtx) => {
parentDuringSetup = ctx.agentExecution.require().agent
parentDuringSetup = ctx.agents.requireInitiator()
explicitChild = agentCtx.agent
agentCtx.tools.register(defineTool({
name: 'observe-child',
@@ -178,7 +174,7 @@ describe('AgentLoop execution context', () => {
parameters: {},
execute: async () => {
await Promise.resolve()
childDuringDriver = ctx.agentExecution.require().agent
childDuringDriver = ctx.agents.requireInitiator()
return [{ type: 'text', text: 'observed' }]
},
}))
@@ -187,7 +183,7 @@ describe('AgentLoop execution context', () => {
child = handle.agent
send(handle.agent, 'run child')
await handle.agent.whenIdle()
parentAfterChild = ctx.agentExecution.require().agent
parentAfterChild = ctx.agents.requireInitiator()
await handle.dispose()
return [{ type: 'text', text: 'child completed' }]
},
@@ -205,7 +201,7 @@ describe('AgentLoop execution context', () => {
expect(explicitChild).toBe(child)
expect(childDuringDriver).toBe(child)
expect(parentAfterChild).toBe(parentHandle.agent)
expect(ctx.agentExecution.current()).toBeUndefined()
expect(ctx.agents.currentInitiator()).toBeUndefined()
await parentHandle.dispose()
await ctx.fiber.dispose()
})
@@ -216,7 +212,7 @@ describe('AgentLoop execution context', () => {
textResponse('done'),
])
const { ctx } = await harness(adapter)
const transport = new TestCapabilityTransport(ctx.agentExecution)
const transport = new TestCapabilityTransport(ctx.agents)
let directAmbient: Agent | undefined
let captured: Agent | undefined
@@ -226,7 +222,7 @@ describe('AgentLoop execution context', () => {
parameters: {},
execute: async () => {
await Promise.resolve()
directAmbient = ctx.agentExecution.current()?.agent
directAmbient = ctx.agents.currentInitiator()
return [{ type: 'text', text: 'ok' }]
},
}))
@@ -235,7 +231,7 @@ describe('AgentLoop execution context', () => {
description: 'call the test capability transport',
parameters: { path: { type: 'string' } },
execute: async (args) => {
captured = ctx.agentExecution.require().agent
captured = ctx.agents.requireInitiator()
const path = (args as { path: string }).path
const headers = await transport.request(path)
return [{ type: 'text', text: JSON.stringify(headers) }]
@@ -271,43 +267,15 @@ describe('AgentLoop execution context', () => {
await handle.dispose()
expect(captured?.status).toBe('disposed')
expect(ctx.agentExecution.current()).toBeUndefined()
expect(ctx.agents.currentInitiator()).toBeUndefined()
await ctx.fiber.dispose()
})
it('keeps AgentLoop inactive until the mandatory provider appears', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
const loopFiber = ctx.plugin(AgentLoop, { agents: [] })
await Promise.resolve()
expect(loopFiber.state).toBe(FiberState.PENDING)
await ctx.plugin(AgentExecutionProvider)
await loopFiber
expect(loopFiber.state).toBe(FiberState.ACTIVE)
await ctx.fiber.dispose()
})
it('drains the old driver before disabling ALS during provider restart', async () => {
const ctx = new Context()
it('drains the old driver before disabling ALS during agent-service restart', async () => {
const adapter = new ReloadAdapter()
const { providerFiber, loopFiber } = await (async (): Promise<Harness> => {
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
const mountedProvider = await ctx.plugin(AgentExecutionProvider)
const mountedLoop = await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return { ctx, providerFiber: mountedProvider, loopFiber: mountedLoop }
})()
const oldService = ctx.agentExecution
adapter.execution = oldService
const { ctx, agentsFiber, loopFiber } = await harness(adapter)
const oldService = ctx.agents
adapter.agents = oldService
const oldHandle = await ctx.agents.create({
sessionId: SessionId('before-restart-session'),
agentOptions: { provider: 'mock', model: 'mock' },
@@ -316,14 +284,14 @@ describe('AgentLoop execution context', () => {
send(oldAgent, 'block')
await adapter.firstStarted.promise
await providerFiber.restart()
await agentsFiber.restart()
await loopFiber.await()
expect(adapter.firstAgentDuringAbort?.id).toBe(oldAgent.id)
expect(adapter.firstAgentDuringAbort?.session).toBe(oldAgent.session)
expect(oldAgent.status).toBe('disposed')
expect(() => oldService.current()).toThrow('agent execution service is disposed')
expect(ctx.agentExecution).not.toBe(oldService)
adapter.execution = ctx.agentExecution
expect(() => oldService.currentInitiator()).toThrow('agent initiator scope is disposed')
expect(ctx.agents).not.toBe(oldService)
adapter.agents = ctx.agents
const newHandle = await ctx.agents.create({
sessionId: SessionId('after-restart-session'),
@@ -346,11 +314,10 @@ describe('AgentLoop execution context', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
const service = ctx.agentExecution
adapter.execution = service
const service = ctx.agents
adapter.agents = service
const handle = await ctx.agents.create({
sessionId: SessionId('root-dispose-session'),
agentOptions: { provider: 'mock', model: 'mock' },
@@ -363,6 +330,6 @@ describe('AgentLoop execution context', () => {
expect(adapter.firstAgentDuringAbort?.id).toBe(agent.id)
expect(adapter.firstAgentDuringAbort?.session).toBe(agent.session)
expect(agent.status).toBe('disposed')
expect(() => service.current()).toThrow('agent execution service is disposed')
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
})
})

View File

@@ -5,7 +5,6 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -21,7 +20,6 @@ async function harness(adapter: MockAdapter) {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
@@ -56,7 +54,6 @@ function send(agent: Agent, text: string) {
describe('Agent', () => {
it('rejects access before context binding and a second driver for one session', async () => {
const ctx = new Context()
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('exclusive-driver'))
const prepared = prepareReactLoopAgent(
@@ -266,8 +263,8 @@ describe('Agent', () => {
// test seam. Then call its disposer twice — the second call hits the
// early-return branch.
const ctx = new Context()
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(SessionId('test'))
const prepared = prepareReactLoopAgent(
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
@@ -386,7 +383,6 @@ describe('Agent', () => {
// `done` (loop exit), not an eager resolve. A bare Agent + direct
// internal driver disposer keeps the emit synchronous.
const ctx = new Context()
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)

View File

@@ -14,7 +14,6 @@ import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -29,7 +28,6 @@ async function harness(adapter: MockAdapter) {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
@@ -200,7 +198,6 @@ describe('Agent.cancel()', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -325,7 +322,6 @@ describe('Agent.cancel()', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)

View File

@@ -10,7 +10,6 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -32,7 +31,6 @@ async function makeCoreContext(): Promise<Context> {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
return ctx
}
@@ -308,7 +306,6 @@ describe('config-driven session id', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
const loopFiber = await ctx.plugin(AgentLoop, {
agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('deferred') }],
})
@@ -331,7 +328,6 @@ describe('config-driven session id', () => {
await ctx1.plugin(SystemPrompt)
await ctx1.plugin(ToolRegistry)
await ctx1.plugin(AgentRegistry)
await ctx1.plugin(AgentExecutionProvider)
await ctx1.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] })
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
@@ -351,7 +347,6 @@ describe('config-driven session id', () => {
await ctx2.plugin(SystemPrompt)
await ctx2.plugin(ToolRegistry)
await ctx2.plugin(AgentRegistry)
await ctx2.plugin(AgentExecutionProvider)
await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
@@ -376,7 +371,6 @@ describe('config-driven session id', () => {
await ctx1.plugin(SystemPrompt)
await ctx1.plugin(ToolRegistry)
await ctx1.plugin(AgentRegistry)
await ctx1.plugin(AgentExecutionProvider)
await ctx1.plugin(AgentLoop, { agents: [] })
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
@@ -393,7 +387,6 @@ describe('config-driven session id', () => {
await ctx2.plugin(SystemPrompt)
await ctx2.plugin(ToolRegistry)
await ctx2.plugin(AgentRegistry)
await ctx2.plugin(AgentExecutionProvider)
await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('sticky-1') }] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
@@ -423,7 +416,6 @@ describe('config-driven session id', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn')
.mockImplementation(() => undefined)

View File

@@ -5,7 +5,6 @@ import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
import * as Invariants from '@deepseek-ai/dsh-invariants'
@@ -24,7 +23,6 @@ async function harness(adapter: MockAdapter) {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
@@ -824,7 +822,6 @@ describe('turn numbering continues across seeded sessions', () => {
await ctx2.plugin(SystemPrompt)
await ctx2.plugin(ToolRegistry)
await ctx2.plugin(AgentRegistry)
await ctx2.plugin(AgentExecutionProvider)
await ctx2.plugin(AgentLoop, { agents: [] })
ctx2.llm.registerAdapter(['mock'], second)
@@ -967,7 +964,6 @@ describe('turn and step boundary recovery', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
@@ -1419,7 +1415,6 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
@@ -1471,7 +1466,6 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
@@ -1527,7 +1521,6 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
@@ -1579,7 +1572,6 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
@@ -1629,7 +1621,6 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)

View File

@@ -6,7 +6,6 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -21,7 +20,6 @@ async function harness(adapter: MockAdapter) {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx

View File

@@ -5,7 +5,6 @@ import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -25,7 +24,6 @@ async function harness(adapter: MockAdapter) {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx

View File

@@ -5,7 +5,6 @@ import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -20,7 +19,6 @@ async function harness(adapter: MockAdapter, persona = '') {
await ctx.plugin(SystemPrompt, { persona })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
@@ -1029,7 +1027,6 @@ describe('agent loop', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, {
agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock' }],
})
@@ -1054,7 +1051,6 @@ describe('agent loop', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, {
agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }],
})

View File

@@ -18,7 +18,6 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import fc from 'fast-check'
@@ -42,7 +41,6 @@ async function harness() {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], new EchoAdapter())
return ctx

View File

@@ -5,7 +5,6 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
@@ -43,7 +42,6 @@ async function loopHarness(): Promise<Context> {
await created.plugin(SystemPrompt, { persona: SYSTEM })
await created.plugin(ToolRegistry)
await created.plugin(AgentRegistry)
await created.plugin(AgentExecutionProvider)
await created.plugin(AgentLoop, { agents: [] })
await created.plugin(LlmDeepSeek)
created.tools.register(defineTool({

View File

@@ -13,7 +13,6 @@ import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-a
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -24,7 +23,6 @@ async function harness(adapter: MockAdapter, persona = 'stable base') {
await ctx.plugin(SystemPrompt, { persona })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx

View File

@@ -11,7 +11,6 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -31,7 +30,6 @@ async function mountPersistentHarness(root: string, adapter: MockAdapter): Promi
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -139,7 +137,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(SystemPrompt)
await ctx2.plugin(ToolRegistry)
await ctx2.plugin(AgentRegistry)
await ctx2.plugin(AgentExecutionProvider)
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
@@ -168,7 +165,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(SystemPrompt)
await ctx2.plugin(ToolRegistry)
await ctx2.plugin(AgentRegistry)
await ctx2.plugin(AgentExecutionProvider)
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
@@ -383,7 +379,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root })
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
@@ -445,7 +440,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(SystemPrompt)
await ctx2.plugin(ToolRegistry)
await ctx2.plugin(AgentRegistry)
await ctx2.plugin(AgentExecutionProvider)
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
@@ -499,7 +493,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(SystemPrompt)
await ctx2.plugin(ToolRegistry)
await ctx2.plugin(AgentRegistry)
await ctx2.plugin(AgentExecutionProvider)
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
@@ -529,7 +522,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(SystemPrompt)
await ctx2.plugin(ToolRegistry)
await ctx2.plugin(AgentRegistry)
await ctx2.plugin(AgentExecutionProvider)
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
@@ -560,7 +552,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
await expect(ctx.agents.resume({ resumeSessionId: SessionId('nope') }))

View File

@@ -8,7 +8,6 @@ import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -20,7 +19,6 @@ async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textRespo
await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return { ctx, loopFiber }

View File

@@ -11,7 +11,6 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import LlmService from '@deepseek-ai/dsh-llm'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -22,7 +21,6 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, {
agents: [],
...maxParallelToolCalls === undefined ? {} : { maxParallelToolCalls },
@@ -346,7 +344,6 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 })
ctx.llm.registerAdapter(['mock'], adapter)
const gated = gatedParallelTool('p')

View File

@@ -14,7 +14,6 @@ import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -25,7 +24,6 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too
await ctx.plugin(SystemPrompt, { persona: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx

View File

@@ -5,7 +5,6 @@ import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-se
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -18,7 +17,6 @@ async function harness(adapter: MockAdapter): Promise<Context> {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(Invariants)
await ctx.plugin(AgentExecutionProvider)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx

View File

@@ -35,9 +35,6 @@
{
"path": "../../core/agent"
},
{
"path": "../../core/agent-execution"
},
{
"path": "../../core/scope"
}

View File

@@ -1,10 +1,10 @@
# dsh-agent
Agent interface, registry, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable.
Agent interface, registry, process-local initiator scope, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable.
## Service: `AgentRegistry` (ctx key: `agents`)
Tracks live agents so UI, hook, and orchestrator plugins can find them without importing the concrete loop package.
Tracks live agents and carries the initiating Agent through asynchronous driver work without importing the concrete loop package.
### Public API
@@ -17,6 +17,17 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-
- `ctx.agents.list(): Agent[]`
- `ctx.agents.roots(): Agent[]` — live agents created without an owning agent context; a resumed lineage-bearing session can still be a runtime root.
#### Initiating Agent scope
`AgentLoop` runs each concrete driver's complete lifetime inside an initiator boundary. Concurrent drivers remain isolated, a child driver shadows its parent, and the parent returns after the child settles. Creation, persistence load, and unpublished setup remain outside the child's boundary, so setup initiated by a parent inherits the parent while `agentCtx.agent` identifies the child explicitly.
- `ctx.agents.currentInitiator(): Agent | undefined` — read the inherited initiator without requiring one.
- `ctx.agents.requireInitiator(): Agent` — read it or throw `no initiating agent is active`.
- `ctx.agents.withInitiator(agent, operation)` — run with one exact Agent and preserve the operation's exact synchronous value or Promise.
- `ctx.agents.withoutInitiator(operation)` — hide an inherited initiator for unrelated process-local work.
The scope carries the `Agent` itself and is process-local. Ambient presence is neither liveness proof nor authorization; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. Teardown rejects new boundaries, lets injected dependents and returned-Promise boundaries drain, then disables the underlying `AsyncLocalStorage`; unreturned work remains owned by the subsystem that detached it. The [initiator-scope decision](../../../docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns the detailed boundary and teardown contract.
#### Factory seam (creation)
Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories.
@@ -72,6 +83,8 @@ The handle every plugin programs against:
## Known Limitations and Deferred Work
- **Initiator scope is process-local** — workers, child processes, HTTP, durable queues, and restarts materialize any required identity explicitly.
- **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work.
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface RFC](../../../docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-agent",
"description": "Agent interface, registry, and event vocabulary for the DeepSeek Harness",
"description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -1,11 +1,13 @@
/**
* Agent registry service. Tracks live agents so plugins can find them without
* depending on the concrete loop package. Agent creation belongs to the loop.
* Agent service: live registry, factory delegation, and process-local
* initiator scope. Concrete creation and driving belong to the loop.
*
* @module @deepseek-ai/dsh-agent
*/
import { Context, getTraceable, Service, symbols } from 'cordis'
import { AsyncLocalStorage } from 'node:async_hooks'
import { isPromise } from 'node:util/types'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
@@ -173,6 +175,8 @@ export interface AgentFactory {
/** Thrown when create/resume is called before an agent factory is registered. */
const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plugin)'
const NO_INITIATOR_MESSAGE = 'no initiating agent is active'
const DISPOSED_INITIATOR_MESSAGE = 'agent initiator scope is disposed'
/** All mutable lifecycle state for one exact registry entry. */
interface AgentEntry {
@@ -192,15 +196,19 @@ interface FactorySlot {
}
/**
* Agent registry (`ctx.agents`): tracks live agents so UI, hook, and
* orchestrator plugins can find them without depending on the concrete loop
* package. Agent *creation* is provided by whichever plugin implements the
* {@link AgentFactory} (`@deepseek-ai/dsh-agent-loop`), registered via
* {@link setFactory}.
* Agent service (`ctx.agents`): tracks live agents and carries the initiating
* Agent through one process-local asynchronous driver chain. Agent *creation*
* is provided by whichever plugin implements the {@link AgentFactory}
* (`@deepseek-ai/dsh-agent-loop`), registered via {@link setFactory}.
*/
export class AgentRegistry extends Service {
private store = new Map<SessionId, AgentEntry>()
private factory: FactorySlot | undefined
private readonly initiators = new AsyncLocalStorage<Agent | undefined>()
private initiatorState: 'active' | 'closing' | 'disposed' = 'active'
private activeInitiatorRuns = 0
private initiatorDrain: PromiseWithResolvers<void> | undefined
private initiatorDisposal: Promise<void> | undefined
constructor(ctx: Context) {
super(ctx, 'agents')
@@ -211,6 +219,54 @@ export class AgentRegistry extends Service {
// accessor body never needs to resolve a scope itself. Effect-scoped:
// unwinds with this service's fiber.
ctx.accessor('agent', { get: () => undefined })
ctx.effect(function* (this: AgentRegistry) {
yield () => this.disposeInitiators()
yield () => { this.closeInitiators() }
}.bind(this), 'agents.initiatorLifecycle()')
}
/**
* Read the Agent that initiated the inherited asynchronous driver chain.
* @returns the inherited Agent, or `undefined` outside a driver and inside an explicit clearing boundary.
* @throws when this service instance has been disposed.
*/
currentInitiator(): Agent | undefined {
this.assertInitiatorsReadable()
return this.initiators.getStore()
}
/**
* Read the initiating Agent and fail when no driver boundary is active.
* @returns the inherited Agent.
* @throws when no initiator is active or this service instance has been disposed.
*/
requireInitiator(): Agent {
const agent = this.currentInitiator()
if (agent === undefined) throw new Error(NO_INITIATOR_MESSAGE)
return agent
}
/**
* Run an operation with one exact Agent as its process-local initiator. The
* exact synchronous value or Promise returned by the operation is preserved.
* @param agent - initiating Agent to inherit; presence is neither liveness proof nor authorization.
* @param operation - synchronous or asynchronous operation to invoke.
* @returns the exact value returned by `operation`.
* @throws when the initiator scope is closing/disposed, or when `operation` throws.
*/
withInitiator<T>(agent: Agent, operation: () => T): T {
return this.runWithInitiator(agent, operation)
}
/**
* Run an operation inside a boundary that hides any inherited initiating
* Agent. The exact synchronous value or Promise is preserved.
* @param operation - synchronous or asynchronous operation to invoke without an initiator.
* @returns the exact value returned by `operation`.
* @throws when the initiator scope is closing/disposed, or when `operation` throws.
*/
withoutInitiator<T>(operation: () => T): T {
return this.runWithInitiator(undefined, operation)
}
/**
@@ -471,6 +527,57 @@ export class AgentRegistry extends Service {
.filter(entry => entry.owner === undefined)
.map(entry => entry.agent)
}
/** Reject new initiator boundaries while inherited continuations drain. */
private closeInitiators(): void {
if (this.initiatorState === 'active') this.initiatorState = 'closing'
}
/** Wait for returned-Promise boundaries, then invalidate retained references. */
private disposeInitiators(): Promise<void> {
return (this.initiatorDisposal ??= (async () => {
this.closeInitiators()
if (this.activeInitiatorRuns !== 0) {
this.initiatorDrain ??= Promise.withResolvers<void>()
await this.initiatorDrain.promise
}
this.initiatorState = 'disposed'
this.initiators.disable()
})())
}
/** Establish one tracked initiator or clearing boundary. */
private runWithInitiator<T>(agent: Agent | undefined, operation: () => T): T {
if (this.initiatorState !== 'active') throw new Error(DISPOSED_INITIATOR_MESSAGE)
this.activeInitiatorRuns += 1
let result: T
try {
result = this.initiators.run(agent, operation)
} catch (error: unknown) {
this.releaseInitiatorRun()
throw error
}
if (isPromise(result)) {
void result.then(
() => { this.releaseInitiatorRun() },
() => { this.releaseInitiatorRun() },
)
} else {
this.releaseInitiatorRun()
}
return result
}
private assertInitiatorsReadable(): void {
if (this.initiatorState === 'disposed') throw new Error(DISPOSED_INITIATOR_MESSAGE)
}
private releaseInitiatorRun(): void {
this.activeInitiatorRuns -= 1
if (this.activeInitiatorRuns !== 0) return
this.initiatorDrain?.resolve()
this.initiatorDrain = undefined
}
}
export default AgentRegistry

View File

@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { runInNewContext } from 'node:vm'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
function agent(id: string): Agent {
return { id: SessionId(id) } as Agent
}
async function harness(): Promise<{
ctx: Context
service: AgentRegistry
dispose: () => Promise<void>
}> {
const ctx = new Context()
const fiber = await ctx.plugin(AgentRegistry)
return {
ctx,
service: ctx.agents,
dispose: fiber.dispose,
}
}
describe('AgentRegistry initiator scope', () => {
it('reports an absent initiator and requires an active boundary', async () => {
const { service, dispose } = await harness()
expect(service.currentInitiator()).toBeUndefined()
expect(() => service.requireInitiator()).toThrow('no initiating agent is active')
await dispose()
})
it('preserves exact synchronous and Promise return identities across await', async () => {
const { service, dispose } = await harness()
const initiator = agent('identity')
const value = { result: true }
expect(service.withInitiator(initiator, () => {
expect(service.requireInitiator()).toBe(initiator)
return value
})).toBe(value)
const promise = service.withInitiator(initiator, async () => {
expect(service.requireInitiator()).toBe(initiator)
await Promise.resolve()
expect(service.requireInitiator()).toBe(initiator)
return value
})
expect(service.withInitiator(initiator, () => promise)).toBe(promise)
await expect(promise).resolves.toBe(value)
expect(service.currentInitiator()).toBeUndefined()
await dispose()
})
it('isolates overlapping initiators', async () => {
const { service, dispose } = await harness()
const a = agent('a')
const b = agent('b')
const bothStarted = Promise.withResolvers<boolean>()
const release = Promise.withResolvers<boolean>()
let starts = 0
const run = (initiator: Agent): Promise<void> => service.withInitiator(initiator, async () => {
expect(service.requireInitiator()).toBe(initiator)
starts += 1
if (starts === 2) bothStarted.resolve(true)
await release.promise
expect(service.requireInitiator()).toBe(initiator)
})
const pending = [run(a), run(b)]
await bothStarted.promise
expect(service.currentInitiator()).toBeUndefined()
release.resolve(true)
await Promise.all(pending)
await dispose()
})
it('restores nested and explicitly cleared boundaries', async () => {
const { service, dispose } = await harness()
const parent = agent('parent')
const child = agent('child')
service.withInitiator(parent, () => {
expect(service.requireInitiator()).toBe(parent)
service.withInitiator(child, () => { expect(service.requireInitiator()).toBe(child) })
expect(service.requireInitiator()).toBe(parent)
service.withoutInitiator(() => {
expect(service.currentInitiator()).toBeUndefined()
expect(() => service.requireInitiator()).toThrow('no initiating agent is active')
})
expect(service.requireInitiator()).toBe(parent)
})
expect(service.currentInitiator()).toBeUndefined()
await dispose()
})
it('restores the parent after synchronous throws and rejected operations', async () => {
const { service, dispose } = await harness()
const parent = agent('parent')
const child = agent('child')
const syncError = new Error('sync failure')
const asyncError = new Error('async failure')
service.withInitiator(parent, () => {
expect(() => service.withInitiator(child, () => { throw syncError })).toThrow(syncError)
expect(service.requireInitiator()).toBe(parent)
})
await expect(service.withInitiator(child, async () => {
await Promise.resolve()
throw asyncError
})).rejects.toBe(asyncError)
expect(service.currentInitiator()).toBeUndefined()
await dispose()
})
it('stops new boundaries, drains active Promises, and invalidates retained references', async () => {
const { ctx, service, dispose } = await harness()
const initiator = agent('draining')
const release = Promise.withResolvers<boolean>()
const pending = service.withInitiator(initiator, async () => {
await release.promise
expect(service.requireInitiator()).toBe(initiator)
})
let disposed = false
const disposal = dispose().then(() => { disposed = true })
await Promise.resolve()
expect(() => service.withInitiator(initiator, () => 1)).toThrow('agent initiator scope is disposed')
expect(() => service.withoutInitiator(() => 1)).toThrow('agent initiator scope is disposed')
expect(disposed).toBe(false)
expect(ctx.get('agents')).toBeUndefined()
release.resolve(true)
await pending
await disposal
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
expect(() => service.requireInitiator()).toThrow('agent initiator scope is disposed')
})
it('drains cross-realm Promise boundaries before disposal', async () => {
const { service, dispose } = await harness()
const initiator = agent('cross-realm')
const release = Promise.withResolvers<boolean>()
const operation = runInNewContext(
'(async () => { await release; inspect() })',
{
release: release.promise,
inspect: () => { expect(service.requireInitiator()).toBe(initiator) },
},
) as () => Promise<void>
const pending = service.withInitiator(initiator, operation)
expect(pending).not.toBeInstanceOf(Promise)
let disposed = false
const disposal = dispose().then(() => { disposed = true })
await Promise.resolve()
expect(disposed).toBe(false)
release.resolve(true)
await pending
await disposal
expect(disposed).toBe(true)
})
})

View File

@@ -6,7 +6,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { collectEvents, collectServices } from '../../../../scripts/gen-cordis-catalog.ts'
import { collectEvents, collectServices, renderEvents, renderServices } from '../../../../scripts/gen-cordis-catalog.ts'
/** Write a fixture package exposing one `interface Events` block and return the
* scan root to hand `collectEvents`. */
@@ -58,6 +58,8 @@ describe('gen-cordis-catalog collectEvents', () => {
))
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' })
expect(events[0]?.jsDoc).toBe('/**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */')
expect(renderEvents(events)).toContain("```ts cordis-catalog\n/**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n'fix/happened'(id: string): void\n```")
})
it('classifies a trailing-next signature as a waterfall', () => {
@@ -158,26 +160,11 @@ export class FixService {
expect(services).toHaveLength(1)
expect(services[0]).toMatchObject({ key: 'fix', type: 'FixService', abstract: false, doc: 'Fixture service.' })
expect(services[0]?.methods).toHaveLength(3)
})
it('extracts an interface service as an abstract seam', () => {
const services = collectServices(makeService(`/** Fixture service interface. */
export interface FixService {
/**
* Do the thing.
* @param id - which thing to do.
* @returns the outcome of doing it.
*/
run(id: string): string
}`))
expect(services).toHaveLength(1)
expect(services[0]).toMatchObject({
key: 'fix',
type: 'FixService',
abstract: true,
doc: 'Fixture service interface.',
expect(services[0]?.methods[0]).toEqual({
signature: 'run(id: string): string',
jsDoc: '/**\n * Do the thing.\n * @param id - which thing to do.\n * @returns the outcome of doing it.\n */',
})
expect(services[0]?.methods).toEqual(['run(id: string): string'])
expect(renderServices(services)).toContain('```ts cordis-catalog\n/**\n * Do the thing.\n * @param id - which thing to do.\n * @returns the outcome of doing it.\n */\nrun(id: string): string\n\n/** Fire and forget (void needs no @returns). */\npoke(): void')
})
it('hard-errors on a public method with no JSDoc at all', () => {