refactor(core): simplify scoped agent lifecycles
This commit is contained in:
@@ -8,16 +8,18 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
|
||||
### Public API
|
||||
|
||||
Lifecycle (scoped and dual-owned): programmatic creation and resume snapshot caller-owned identity/configuration data, obtain registry/store-owned capabilities for both unpublished IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly; the trace-bound `AgentLoop` receiver still supplies the dependency origin, so a caller that injects only `agents` can create an agent whose scope reaches the loop's `sessions`/`llm`/`tools`/`systemPrompt` surface. The caller owns cancellation and the returned handle, while AgentLoop remains a structural second owner because the live driver depends on that service surface: unloading the provider aborts pending load/setup, tears down live programmatic agents, and awaits the same quiescence and ID-release boundary before its dependencies disappear.
|
||||
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
|
||||
|
||||
The complete create transaction is factory-tracked before ID reservation/session validation, and both a factory placeholder and lifecycle-long caller sentinel exist before scope minting can reenter plugin lifecycle notifications. The caller sentinel adopts the exact reservation effects and always follows the memoized lifecycle boundary, including handle-first teardown followed by caller unload. Resume adds a load sentinel before persistence I/O; it waits for load settlement until `startOwned` synchronously returns a lifecycle/rollback disposer, then follows that disposer without a handoff gap. The ID capabilities reject competing `register`/`enter`/`prepare`/`create` calls and remain held through scope quiescence. A create hands one-read raw seed and metadata references synchronously to the session boundary, which rejects exotic shells and materializes accepted values in a single recursive pass; pre-cloning either value could incorrectly sanitize prototypes. Resume captures each loaded metadata field once. After setup resolves, the factory checks caller and provider liveness after constructing both registry entries but before the first announcement, after `session/created`, after `agent/created`, and again after `agent/session-start` before starting the driver, so synchronous getter- or listener-triggered teardown wins. A publication-wide barrier flips lifecycle liveness immediately but keeps both entries and `agent.ctx` intact until the current synchronous notification phase unwinds; only then does rollback revoke them. Registry/store entries claim IDs across caller-code commit windows, detach exact objects only, and reuse stable carriers for paired edges. Load/setup rejection or owner unload before announcement emits no creation edge; if teardown begins inside a creation or session-start listener, the already-started notifications are paired during rollback and no live or drivable publication survives. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope → release reservations. After quiescence, the caller sentinel and any resume-load sentinel disarm and remove their owner-fiber effects so a long-lived caller does not retain the completed agent and scope. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`; the registry's paired disposal edge applies the same failure containment through its captured carrier. Per-step assembly goes through `assembleContextFor(agent)`, and the turn-end durability checkpoint goes through `ctx.sessions.flush(session)`.
|
||||
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
|
||||
|
||||
IDs are caller-chosen and assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same agent or session id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; IDs become reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
|
||||
|
||||
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-<uuid>` with optional metadata; the uuid avoids colliding with a prior durable log. Each call is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
|
||||
|
||||
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
|
||||
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup? }): Promise<AgentHandle>` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and detaches each raw value in one pass. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
|
||||
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`.
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
|
||||
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
|
||||
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.
|
||||
|
||||
@@ -44,7 +46,7 @@ Agents listed in config are auto-created at startup. `cwd` applies only to fresh
|
||||
|
||||
- `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
|
||||
`Inbox`, `runLoop`, and the instance-bound enable/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary.
|
||||
`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary.
|
||||
|
||||
### Loop lifecycle (`loop.ts`)
|
||||
|
||||
@@ -91,7 +93,7 @@ forever:
|
||||
idle unless more queued
|
||||
```
|
||||
|
||||
Error containment: a throwing plugin ends the **turn**, never the loop. A malformed or throwing `agent/turn-stop` policy likewise fails the turn closed. A successful terminal stop stays authoritative through `turn/end` and `session/flush`, preventing their listeners from resurrecting steering through the late fallback. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop.
|
||||
Error containment: a throwing plugin ends the **turn**, never the loop. A throwing `agent/turn-stop` policy likewise fails the turn closed. A successful terminal stop stays authoritative through `turn/end` and `session/flush`, preventing their listeners from resurrecting steering through the late fallback. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop.
|
||||
|
||||
Cancellation: `agent.cancel()` is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step `AbortController` directly on disposal and from `cancel()`; that controller is loop-internal, not a public verb.)
|
||||
|
||||
|
||||
@@ -16,9 +16,6 @@ import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session'
|
||||
import { Inbox, type InboxMessage } from './inbox.ts'
|
||||
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
|
||||
/** Agents whose rollback-covered publication enabled driving. */
|
||||
const driveEnabledAgents = new WeakSet<ReactLoopAgent>()
|
||||
|
||||
/** Sessions already claimed by a concrete driver construction. */
|
||||
const claimedDriverSessions = new WeakSet<Session>()
|
||||
|
||||
@@ -28,12 +25,18 @@ const startDriver = Symbol('dsh.agent-loop.start-driver')
|
||||
/** Module-private quiescent stop, valid both before and after driver start. */
|
||||
const stopDriver = Symbol('dsh.agent-loop.stop-driver')
|
||||
|
||||
/** Module-private context binding for the mutually referential agent scope. */
|
||||
const bindContext = Symbol('dsh.agent-loop.bind-context')
|
||||
|
||||
/** Module-private publication marker. */
|
||||
const publishAgent = Symbol('dsh.agent-loop.publish-agent')
|
||||
|
||||
/** Factory-owned controls that can operate only on the agent created with them. */
|
||||
export interface PreparedReactLoopAgent {
|
||||
/** The unpublished concrete agent. */
|
||||
agent: ReactLoopAgent
|
||||
/** Open its driving verbs at the rollback-covered publication boundary. */
|
||||
enableDrive(): void
|
||||
/** Mark the agent public so teardown emits its status lifecycle. */
|
||||
markPublished(): void
|
||||
/** Stop the prepared instance even when publication has not started its loop. */
|
||||
dispose(): Promise<void> | void
|
||||
/**
|
||||
@@ -48,7 +51,7 @@ export interface PreparedReactLoopAgent {
|
||||
* Construct one concrete agent together with unforgeable, instance-bound
|
||||
* lifecycle controls. The package surface deliberately exposes neither source
|
||||
* subpaths nor this helper: setup code may identify the concrete class, but it
|
||||
* cannot enable or start the factory's unpublished instance.
|
||||
* cannot publish or start the factory's unpublished instance.
|
||||
* @param ctx - the agent-loop service context used for driving and events.
|
||||
* @param id - the concrete agent identity.
|
||||
* @param options - loop options for the agent.
|
||||
@@ -62,14 +65,11 @@ export function prepareReactLoopAgent(
|
||||
throw new Error(`session "${session.id}" already has a concrete agent driver`)
|
||||
}
|
||||
const agent = new ReactLoopAgent(ctx, id, options, session)
|
||||
// Construction snapshots caller options and can throw. Claim only the fully
|
||||
// initialized driver so the same prepared session remains retryable after a
|
||||
// rejected caller value.
|
||||
claimedDriverSessions.add(session)
|
||||
const dispose = () => agent[stopDriver]()
|
||||
return {
|
||||
agent,
|
||||
enableDrive: () => { driveEnabledAgents.add(agent) },
|
||||
markPublished: () => { agent[publishAgent]() },
|
||||
dispose,
|
||||
startDriver: () => {
|
||||
agent[startDriver]()
|
||||
@@ -82,20 +82,12 @@ export function prepareReactLoopAgent(
|
||||
* Install the concrete agent's scope context exactly once. Construction and
|
||||
* scope minting are mutually referential (the scope key is the agent), so the
|
||||
* factory performs this one post-construction binding before setup receives
|
||||
* the unpublished agent. The runtime slot is non-writable/non-configurable;
|
||||
* TypeScript `readonly` alone would still let JavaScript redirect later
|
||||
* registrations to another context.
|
||||
* the unpublished agent. The module-private binding rejects a second bind.
|
||||
* @param agent - the unpublished concrete agent to bind.
|
||||
* @param ctx - its fully extended agent scope context.
|
||||
*/
|
||||
export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): void {
|
||||
if (Object.hasOwn(agent, 'ctx')) throw new Error(`agent "${agent.id}" context is already bound`)
|
||||
Object.defineProperty(agent, 'ctx', {
|
||||
value: ctx,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
agent[bindContext](ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,7 +98,7 @@ export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context):
|
||||
* the agent/* event taxonomy — plugins never need this class.
|
||||
*/
|
||||
export class ReactLoopAgent implements Agent {
|
||||
/** Queued + steering FIFOs; native-private so setup cannot bypass driving verbs. */
|
||||
/** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */
|
||||
readonly #inbox = new Inbox()
|
||||
|
||||
/**
|
||||
@@ -117,12 +109,20 @@ export class ReactLoopAgent implements Agent {
|
||||
* context are mutually referential (the scope is keyed BY this agent), so
|
||||
* neither can exist strictly before the other.
|
||||
*/
|
||||
declare readonly ctx: Context
|
||||
private boundContext: Context | undefined
|
||||
|
||||
/** The agent's scoped composition context, bound once by its factory. */
|
||||
get ctx(): Context {
|
||||
if (this.boundContext === undefined) throw new Error(`agent "${this.id}" context is not bound`)
|
||||
return this.boundContext
|
||||
}
|
||||
|
||||
private _status: AgentStatus = 'idle'
|
||||
private currentAbort: AbortController | undefined
|
||||
/** Whether runLoop has been installed into {@link done}. */
|
||||
private driverStarted = false
|
||||
/** Whether registry publication began and status disposal is externally visible. */
|
||||
private published = false
|
||||
/**
|
||||
* Turn-scoped cancel marker, set by {@link cancel} and read/cleared by the
|
||||
* driver loop (via the LoopHandle) at every point a turn could start or
|
||||
@@ -167,16 +167,6 @@ export class ReactLoopAgent implements Agent {
|
||||
public readonly options: AgentOptions,
|
||||
public readonly session: Session,
|
||||
) {
|
||||
const acceptedOptions = deepFreeze(structuredClone(options))
|
||||
// Pin the public ownership/identity bindings in the runtime object. A
|
||||
// JavaScript caller can otherwise replace TS-readonly parameter properties
|
||||
// after publication and split the registry, driver, session, and model
|
||||
// configuration into different worlds.
|
||||
Object.defineProperties(this, {
|
||||
id: { value: id, enumerable: true, writable: false, configurable: false },
|
||||
options: { value: acceptedOptions, enumerable: true, writable: false, configurable: false },
|
||||
session: { value: session, enumerable: true, writable: false, configurable: false },
|
||||
})
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
this.disposed = promise
|
||||
this.resolveDisposed = resolve
|
||||
@@ -233,36 +223,24 @@ export class ReactLoopAgent implements Agent {
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
}
|
||||
|
||||
/** Reject every driving verb while creation setup still owns the agent. */
|
||||
private assertDriveEnabled(action: string): void {
|
||||
if (driveEnabledAgents.has(this)) return
|
||||
throw new Error(`agent "${this.id}" cannot ${action} before creation setup completes`)
|
||||
}
|
||||
|
||||
send(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertDriveEnabled('send')
|
||||
this.assertNotDisposed()
|
||||
const accepted = this.acceptInboxMessage(content, options)
|
||||
// Materialization invokes caller getters, which may reenter handle disposal.
|
||||
this.assertNotDisposed()
|
||||
this.#inbox.enqueue(accepted)
|
||||
const info = deepFreeze({ source: accepted.source, steering: false })
|
||||
const info = { source: accepted.source, steering: false } as const
|
||||
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
|
||||
}
|
||||
|
||||
steer(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertDriveEnabled('steer')
|
||||
this.assertNotDisposed()
|
||||
if (this._status !== 'running') { this.send(content, options); return }
|
||||
const accepted = this.acceptInboxMessage(content, options)
|
||||
this.assertNotDisposed()
|
||||
this.#inbox.steer(accepted)
|
||||
const info = deepFreeze({ source: accepted.source, steering: true })
|
||||
const info = { source: accepted.source, steering: true } as const
|
||||
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
|
||||
}
|
||||
|
||||
inject(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertDriveEnabled('inject')
|
||||
this.assertNotDisposed()
|
||||
const source = this.resolveSource(options)
|
||||
if (isTurnOpen(this.session)) {
|
||||
@@ -323,7 +301,6 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
cancel(reason?: string): void {
|
||||
this.assertDriveEnabled('cancel')
|
||||
// Arm-gate: only mark a cancellation when there is actually work to cancel —
|
||||
// a running turn, an in-flight step, or queued/steering work. An idle cancel
|
||||
// with nothing pending is a true no-op; arming the marker then would wrongly
|
||||
@@ -382,6 +359,17 @@ export class ReactLoopAgent implements Agent {
|
||||
})
|
||||
}
|
||||
|
||||
/** Bind the mutually referential scope context once. */
|
||||
private [bindContext](ctx: Context): void {
|
||||
if (this.boundContext !== undefined) throw new Error(`agent "${this.id}" context is already bound`)
|
||||
this.boundContext = ctx
|
||||
}
|
||||
|
||||
/** Mark that public lifecycle publication began. */
|
||||
private [publishAgent](): void {
|
||||
this.published = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the driver loop. The prepared controller already owns its stable
|
||||
* disposer, so teardown can mark the agent disposed even in the narrow
|
||||
@@ -424,15 +412,15 @@ export class ReactLoopAgent implements Agent {
|
||||
this.settleIdleWaiters()
|
||||
this.currentAbort?.abort('disposed')
|
||||
// An unpublished rollback has no public status lifecycle to announce.
|
||||
// Once driving is enabled, disposed is part of the agent/status contract.
|
||||
if (driveEnabledAgents.has(this)) {
|
||||
// Once publication begins, disposed is part of the agent/status contract.
|
||||
if (this.published) {
|
||||
agentEvents(this.loopCtx, this).emit('agent/status', 'disposed')
|
||||
}
|
||||
}
|
||||
// Before runLoop starts there is normally nothing asynchronous to drain;
|
||||
// keep publication rollback synchronous so create() cannot throw while its
|
||||
// session/agent entries are still briefly live. A session-start listener
|
||||
// may have used the newly enabled inject() surface, however, so preserve
|
||||
// may have called inject(), however, so preserve
|
||||
// its durability checkpoint as a real quiescence boundary.
|
||||
if (!this.driverStarted && this.pendingIdleFlushes.size === 0) return
|
||||
return this.drainDriver()
|
||||
@@ -455,11 +443,7 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Render an arbitrary thrown value without allowing coercion to throw again. */
|
||||
/** Render an ordinary thrown value for the error event and log. */
|
||||
function renderThrown(value: unknown): string {
|
||||
try {
|
||||
return value instanceof Error ? value.message : String(value)
|
||||
} catch {
|
||||
return '<unrenderable thrown value>'
|
||||
}
|
||||
return value instanceof Error ? value.message : String(value)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import type { Context } from 'cordis'
|
||||
import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, ContinuationStop, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
|
||||
@@ -36,20 +36,6 @@ function toError(error: unknown): CodedError {
|
||||
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the runtime result of the terminal-stop serial event. Event types
|
||||
* protect TypeScript listeners, but JavaScript and casts can still return an
|
||||
* arbitrary bail value; accepting one as an implicit stop would hide a broken
|
||||
* policy plugin.
|
||||
*/
|
||||
function assertContinuationStop(value: unknown): asserts value is ContinuationStop | undefined {
|
||||
if (value === undefined) return
|
||||
const candidate = Object(value) as { action?: unknown }
|
||||
if (candidate.action !== 'stop') {
|
||||
throw new Error('agent/turn-stop returned an invalid result; expected { action: \'stop\' } or undefined')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a model-call {@link FinishReason} to the step error it should raise, or
|
||||
* `undefined` when the step completed normally.
|
||||
@@ -644,8 +630,7 @@ async function runTurn(
|
||||
// no later listener or steering override can resurrect the turn.
|
||||
let terminalStop = false
|
||||
try {
|
||||
const stop = await events.strictSerial('agent/turn-stop', turn)
|
||||
assertContinuationStop(stop)
|
||||
const stop = await events.serial('agent/turn-stop', turn)
|
||||
terminalStop = stop !== undefined
|
||||
} catch (error: unknown) {
|
||||
// A broken terminal policy is an ordinary continuation failure: fail
|
||||
|
||||
@@ -49,30 +49,15 @@ function send(agent: ReactLoopAgent, text: string) {
|
||||
}
|
||||
|
||||
describe('ReactLoopAgent', () => {
|
||||
it('owns immutable runtime bindings for id, options, session, and scoped context', async () => {
|
||||
it('borrows caller options and binds its scoped context exactly once', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('unused')]))
|
||||
const options = { model: 'mock' }
|
||||
const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options)
|
||||
const acceptedSession = agent.session
|
||||
const acceptedContext = agent.ctx
|
||||
|
||||
options.model = 'caller-mutated'
|
||||
expect(agent.options).toEqual({ model: 'mock' })
|
||||
expect(Object.isFrozen(agent.options)).toBe(true)
|
||||
expect(Reflect.set(agent, 'id', AgentId('redirected'))).toBe(false)
|
||||
expect(Reflect.set(agent, 'options', { model: 'other' })).toBe(false)
|
||||
expect(Reflect.set(agent, 'session', ctx.sessions.create(SessionId('other')))).toBe(false)
|
||||
expect(Reflect.set(agent, 'ctx', new Context())).toBe(false)
|
||||
expect(agent.options).toBe(options)
|
||||
expect(agent.id).toBe('owned-bindings')
|
||||
expect(agent.session).toBe(acceptedSession)
|
||||
expect(agent.ctx).toBe(acceptedContext)
|
||||
expect(agent.session.id).toMatch(/^owned-bindings-session-/)
|
||||
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
|
||||
for (const name of ['id', 'options', 'session', 'ctx']) {
|
||||
expect(Object.getOwnPropertyDescriptor(agent, name)).toMatchObject({
|
||||
configurable: false,
|
||||
writable: false,
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -223,23 +208,6 @@ describe('ReactLoopAgent', () => {
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('idle inject() safely renders a hostile non-Error flush failure', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('ok')]))
|
||||
const hostile = { [Symbol.toPrimitive]() { throw new Error('no coercion') } }
|
||||
ctx.on('session/flush', () => { throw hostile })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(AgentId('hostile-flush'), { model: 'mock' })
|
||||
const errors: string[] = []
|
||||
ctx.on('agent/error', (_a, _turn, _step, error) => void errors.push(error.message))
|
||||
|
||||
agent.inject([{ type: 'text', text: 'notice' }])
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
expect(errors).toEqual(['<unrenderable thrown value>'])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('<unrenderable thrown value>'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -281,7 +249,7 @@ describe('ReactLoopAgent', () => {
|
||||
|
||||
// Start the loop to get the disposer; the agent waits for messages
|
||||
// (idle, never-resolving cancel), so it will stay idle.
|
||||
prepared.enableDrive()
|
||||
prepared.markPublished()
|
||||
const dispose = prepared.startDriver()
|
||||
|
||||
// First dispose
|
||||
@@ -309,24 +277,6 @@ describe('ReactLoopAgent', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not claim a session when concrete-agent construction rejects options', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('constructor-retry'))
|
||||
const badOptions = {
|
||||
get model(): string {
|
||||
throw new Error('bad model getter')
|
||||
},
|
||||
}
|
||||
|
||||
expect(() => prepareReactLoopAgent(ctx, AgentId('bad-constructor'), badOptions, session))
|
||||
.toThrow('bad model getter')
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('constructor-retry'), { model: 'mock' }, session)
|
||||
await prepared.dispose()
|
||||
expect(prepared.agent.status).toBe('disposed')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('setting the same status does not emit agent/status again', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -417,7 +367,7 @@ describe('ReactLoopAgent', () => {
|
||||
const session = ctx.sessions.create(SessionId('bare'))
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const { agent } = prepared
|
||||
prepared.enableDrive()
|
||||
prepared.markPublished()
|
||||
const dispose = prepared.startDriver()
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
@@ -99,22 +99,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('createAgent sends raw metadata to the session validator before cloning can sanitize it', async () => {
|
||||
class ExoticMeta {
|
||||
readonly cwd = '/accepted'
|
||||
}
|
||||
const { ctx } = await persistentHarness(new MockAdapter([textResponse('hi')]))
|
||||
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('exotic-meta-agent'),
|
||||
sessionId: SessionId('exotic-meta-session'),
|
||||
meta: new ExoticMeta(),
|
||||
})).rejects.toThrow(/session metadata is not a plain JSON record/)
|
||||
expect(ctx.agents.get(AgentId('exotic-meta-agent'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('exotic-meta-session'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume of a session with no cwd carries an undefined cwd header', async () => {
|
||||
// Lifecycle 1: create a no-cwd session and run a turn.
|
||||
const adapter1 = new MockAdapter([textResponse('a')])
|
||||
@@ -184,7 +168,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
order.push('session/created')
|
||||
})
|
||||
ctx.on('agent/created', (agent) => {
|
||||
expect(() => { agent.cancel('too early') }).toThrow(/cannot cancel before creation setup completes/)
|
||||
expect(agent.status).toBe('idle')
|
||||
order.push('agent/created')
|
||||
})
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
@@ -228,9 +212,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('successful resume disposal retires both caller ownership sentinels', async () => {
|
||||
const sessionId = SessionId('resume-retired-sentinels-s')
|
||||
const agentId = AgentId('resume-retired-sentinels')
|
||||
it('successful resume disposal retires its caller-owned transaction effects', async () => {
|
||||
const sessionId = SessionId('resume-retired-effects-s')
|
||||
const agentId = AgentId('resume-retired-effects')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const handle = await ctx.agents.resume({
|
||||
@@ -238,14 +222,14 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const sentinelLabels = [
|
||||
`agentLoop.resumeLoad(${agentId})`,
|
||||
`agentLoop.ownerLifecycle(${agentId})`,
|
||||
const transactionLabels = [
|
||||
`agentLoop.owner(${agentId})`,
|
||||
`agentLoop.lifecycle(${agentId})`,
|
||||
]
|
||||
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(sentinelLabels))
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels))
|
||||
await handle.dispose()
|
||||
expect(ctx.fiber.getEffects().filter(effect => sentinelLabels.includes(effect.label))).toEqual([])
|
||||
expect(ctx.fiber.getEffects().filter(effect => transactionLabels.includes(effect.label))).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -346,14 +330,14 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
}, { inject: ['agents'] }))
|
||||
await loadStarted.promise
|
||||
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/)
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
|
||||
await promptly(owner.dispose())
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
|
||||
// owner.dispose() itself awaited transaction settlement and reservation
|
||||
// release: reuse the same identities BEFORE awaiting the resume rejection.
|
||||
// owner.dispose() awaited transaction settlement, so the same identities
|
||||
// can be reused before awaiting the public rejection.
|
||||
const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }))
|
||||
await rejection
|
||||
expect(loads).toBe(2)
|
||||
@@ -372,7 +356,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('AgentLoop unload aborts persistence load and awaits reservation release', async () => {
|
||||
it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
|
||||
const sessionId = SessionId('resume-load-factory-unload')
|
||||
const agentId = AgentId('resume-load-factory-race')
|
||||
const root = await persistSession(sessionId)
|
||||
@@ -400,18 +384,13 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
|
||||
const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
|
||||
await loadStarted.promise
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/)
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/)
|
||||
await promptly(loopFiber.dispose())
|
||||
await rejection
|
||||
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
const agentReservation = ctx.agents.reserve(agentId)
|
||||
const sessionReservation = ctx.sessions.reserve(sessionId)
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
|
||||
lateLoad.resolve(structuredClone(snapshot))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
@@ -419,83 +398,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('option snapshot reentrancy cannot install a resume sentinel after factory unload begins', async () => {
|
||||
const sessionId = SessionId('resume-snapshot-factory-unload')
|
||||
const agentId = AgentId('resume-snapshot-factory-race')
|
||||
const root = await persistSession(sessionId)
|
||||
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 = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
|
||||
|
||||
let loads = 0
|
||||
const load = ctx.sessionPersistence.load.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.load = (id) => {
|
||||
loads += 1
|
||||
return load(id)
|
||||
}
|
||||
const options = {
|
||||
agentId,
|
||||
resumeSessionId: sessionId,
|
||||
get agentOptions() {
|
||||
void loopFiber.dispose()
|
||||
return { model: 'mock' }
|
||||
},
|
||||
}
|
||||
|
||||
await expect(ctx.agents.resume(options)).rejects.toThrow('agent loop is not active')
|
||||
await loopFiber.dispose()
|
||||
expect(loads).toBe(0)
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.resumeLoad(${agentId})`)).toEqual([])
|
||||
const agentReservation = ctx.agents.reserve(agentId)
|
||||
const sessionReservation = ctx.sessions.reserve(sessionId)
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('snapshots resume identities and agent options before persistence load', async () => {
|
||||
const sessionId = SessionId('resume-snapshot-source')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const loaded = await ctx.sessionPersistence.load(sessionId)
|
||||
const loadGate = Promise.withResolvers<typeof loaded>()
|
||||
ctx.sessionPersistence.load = () => loadGate.promise
|
||||
|
||||
const occupied = await ctx.agents.create({
|
||||
agentId: AgentId('occupied-agent'),
|
||||
sessionId: SessionId('occupied-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const options = {
|
||||
agentId: AgentId('accepted-agent'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
}
|
||||
const resuming = ctx.agents.resume(options)
|
||||
|
||||
options.agentId = AgentId('occupied-agent')
|
||||
options.resumeSessionId = SessionId('occupied-session')
|
||||
options.agentOptions.model = 'mutated-model'
|
||||
loadGate.resolve(structuredClone(loaded))
|
||||
|
||||
const resumed = await resuming
|
||||
expect(resumed.agent.id).toBe(AgentId('accepted-agent'))
|
||||
expect(resumed.agent.session.id).toBe(sessionId)
|
||||
expect(resumed.agent.options.model).toBe('mock')
|
||||
expect(ctx.agents.get(AgentId('occupied-agent'))).toBe(occupied.agent)
|
||||
expect(ctx.sessions.get(SessionId('occupied-session'))).toBe(occupied.agent.session)
|
||||
|
||||
await resumed.dispose()
|
||||
await occupied.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
|
||||
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
|
||||
// in its header) by creating it with a complete-turn seed — the write path
|
||||
@@ -535,54 +437,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reads each loaded metadata field once before reconstructing a resumed session', async () => {
|
||||
const sessionId = SessionId('resume-loaded-meta-once')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const loaded = await ctx.sessionPersistence.load(sessionId)
|
||||
const reads = { createdAt: 0, cwd: 0, parentSession: 0, seedLength: 0 }
|
||||
const meta = Object.defineProperties({
|
||||
version: loaded.meta.version,
|
||||
id: loaded.meta.id,
|
||||
}, {
|
||||
createdAt: {
|
||||
enumerable: true,
|
||||
get: () => { reads.createdAt += 1; return reads.createdAt === 1 ? loaded.meta.createdAt : 1n },
|
||||
},
|
||||
cwd: {
|
||||
enumerable: true,
|
||||
get: () => { reads.cwd += 1; return reads.cwd === 1 ? '/loaded' : 'relative' },
|
||||
},
|
||||
parentSession: {
|
||||
enumerable: true,
|
||||
get: () => { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n },
|
||||
},
|
||||
seedLength: {
|
||||
enumerable: true,
|
||||
get: () => { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n },
|
||||
},
|
||||
}) as unknown as SessionHeader
|
||||
ctx.sessionPersistence.load = () => Promise.resolve({ meta, events: loaded.events })
|
||||
|
||||
const resumed = await ctx.agents.resume({
|
||||
agentId: AgentId('resume-loaded-meta-once'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
|
||||
expect(reads).toEqual({ createdAt: 1, cwd: 1, parentSession: 1, seedLength: 1 })
|
||||
expect(resumed.agent.session.header).toEqual({
|
||||
version: loaded.meta.version,
|
||||
id: sessionId,
|
||||
createdAt: loaded.meta.createdAt,
|
||||
cwd: '/loaded',
|
||||
parentSession: 'parent',
|
||||
seedLength: 0,
|
||||
})
|
||||
await resumed.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => {
|
||||
// Lifecycle 1: run a turn, then inject context while idle. The idle inject
|
||||
// wraps its context/message in a one-shot turn AND checkpoints it (the turn-enclosure RFC)
|
||||
|
||||
@@ -443,14 +443,12 @@ describe('MEDIUM: misc registry and config fixes', () => {
|
||||
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
|
||||
let notifiedContent: ContentBlock[] | undefined
|
||||
let notifiedSource: MessageSource | undefined
|
||||
let notifiedInfoFrozen = false
|
||||
ctx.on('agent/queued', (subject, acceptedContent, info) => {
|
||||
if (subject !== agent || info.steering) return
|
||||
// Retain the exact notification references: cloning here would test the
|
||||
// listener's copy rather than the event/inbox ownership boundary.
|
||||
notifiedContent = acceptedContent
|
||||
notifiedSource = info.source
|
||||
notifiedInfoFrozen = Object.isFrozen(info)
|
||||
})
|
||||
|
||||
agent.send(content, { source })
|
||||
@@ -463,7 +461,6 @@ describe('MEDIUM: misc registry and config fixes', () => {
|
||||
expect(Object.isFrozen(notifiedContent)).toBe(true)
|
||||
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
|
||||
expect(Object.isFrozen(notifiedSource)).toBe(true)
|
||||
expect(notifiedInfoFrozen).toBe(true)
|
||||
const recorded = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
expect(recorded).toContainEqual({
|
||||
content: [{ type: 'text', text: 'accepted-send' }],
|
||||
@@ -474,33 +471,6 @@ describe('MEDIUM: misc registry and config fixes', () => {
|
||||
expect(request).not.toContain('caller-mutated-send')
|
||||
})
|
||||
|
||||
it('send() rechecks disposal after materializing caller getters', async () => {
|
||||
const adapter = new MockAdapter([textResponse('unused')])
|
||||
const ctx = await harness(adapter)
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('reentrant-send-dispose'),
|
||||
sessionId: SessionId('reentrant-send-dispose-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const { agent } = handle
|
||||
let queued = 0
|
||||
ctx.on('agent/queued', subject => void (queued += Number(subject === agent)))
|
||||
const content = [{
|
||||
type: 'text' as const,
|
||||
get text() {
|
||||
void handle.dispose()
|
||||
return 'accepted-after-dispose'
|
||||
},
|
||||
}]
|
||||
|
||||
expect(() => { agent.send(content) }).toThrow(/agent "reentrant-send-dispose" is disposed/)
|
||||
await handle.dispose()
|
||||
|
||||
expect(queued).toBe(0)
|
||||
expect(agent.session.events).toHaveLength(0)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('running steer() owns content and source before notification and delivery', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -519,12 +489,10 @@ describe('MEDIUM: misc registry and config fixes', () => {
|
||||
}))
|
||||
let notifiedContent: ContentBlock[] | undefined
|
||||
let notifiedSource: MessageSource | undefined
|
||||
let notifiedInfoFrozen = false
|
||||
ctx.on('agent/queued', (subject, acceptedContent, info) => {
|
||||
if (subject !== agent || !info.steering) return
|
||||
notifiedContent = acceptedContent
|
||||
notifiedSource = info.source
|
||||
notifiedInfoFrozen = Object.isFrozen(info)
|
||||
})
|
||||
|
||||
agent.send([{ type: 'text', text: 'start' }])
|
||||
@@ -544,7 +512,6 @@ describe('MEDIUM: misc registry and config fixes', () => {
|
||||
expect(Object.isFrozen(notifiedContent)).toBe(true)
|
||||
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
|
||||
expect(Object.isFrozen(notifiedSource)).toBe(true)
|
||||
expect(notifiedInfoFrozen).toBe(true)
|
||||
const recorded = agent.session.events.flatMap(event => event.type === 'steering/message' ? [event.data] : [])
|
||||
expect(recorded).toContainEqual({
|
||||
turn: 1,
|
||||
@@ -579,7 +546,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
|
||||
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
|
||||
const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
|
||||
const forked = prepared.agent
|
||||
prepared.enableDrive()
|
||||
prepared.markPublished()
|
||||
ctx2.effect(() => prepared.startDriver())
|
||||
|
||||
const turns: number[] = []
|
||||
|
||||
@@ -8,7 +8,6 @@ import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepse
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as concreteAgentModule from '../src/agent.ts'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
@@ -46,7 +45,7 @@ function disposeCurrentLifecycle(ownerCtx: Context): void {
|
||||
const lifecycle = [...ownerCtx.fiber._disposables]
|
||||
.find((dispose) => {
|
||||
const effect = (dispose as typeof dispose & { [symbols.effect]?: EffectMeta })[symbols.effect]
|
||||
return effect?.label === 'agentLoop.lifecycle()'
|
||||
return effect?.label.startsWith('agentLoop.lifecycle(') === true
|
||||
})
|
||||
if (lifecycle === undefined) throw new Error('agent lifecycle effect not found')
|
||||
void lifecycle()
|
||||
@@ -167,11 +166,9 @@ describe('agent scope lifecycle', () => {
|
||||
expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined()
|
||||
expect(order).toEqual(['setup:start'])
|
||||
acceptedOptions.model = 'mutated while setup was pending'
|
||||
|
||||
gate.resolve(undefined)
|
||||
const handle = await creating
|
||||
expect(handle.agent.options.model).toBe('mock')
|
||||
expect(handle.agent.options).toBe(acceptedOptions)
|
||||
expect(order).toEqual([
|
||||
'setup:start',
|
||||
'setup:end',
|
||||
@@ -184,90 +181,80 @@ describe('agent scope lifecycle', () => {
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('reserves agent and session ids across concurrent async setup', async () => {
|
||||
it('lets the final enter arbitrate unsupported concurrent same-id creation and rolls the loser back', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const bothStarted = Promise.withResolvers<undefined>()
|
||||
let started = 0
|
||||
const setup = async (): Promise<void> => {
|
||||
started += 1
|
||||
if (started === 2) bothStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
}
|
||||
const agentId = AgentId('concurrent-final-enter')
|
||||
const first = ctx.agents.create({
|
||||
agentId: AgentId('reserved'),
|
||||
sessionId: SessionId('reserved-s'),
|
||||
agentId,
|
||||
sessionId: SessionId('concurrent-final-enter-a'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: () => gate.promise,
|
||||
setup,
|
||||
})
|
||||
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('reserved'),
|
||||
sessionId: SessionId('other-s'),
|
||||
const second = ctx.agents.create({
|
||||
agentId,
|
||||
sessionId: SessionId('concurrent-final-enter-b'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})).rejects.toThrow(/already registered/)
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('other'),
|
||||
sessionId: SessionId('reserved-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})).rejects.toThrow(/already exists/)
|
||||
setup,
|
||||
})
|
||||
await bothStarted.promise
|
||||
expect(ctx.agents.list()).toEqual([])
|
||||
expect(ctx.sessions.list()).toEqual([])
|
||||
|
||||
gate.resolve(undefined)
|
||||
const handle = await first
|
||||
await handle.dispose()
|
||||
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)
|
||||
expect(String(rejected[0]!.reason)).toMatch(/already registered/)
|
||||
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([])
|
||||
})
|
||||
|
||||
it('makes setup-time publication structurally impossible through public stores', async () => {
|
||||
it('uses signal only for creation: aborts pending setup but not a returned live handle', async () => {
|
||||
const ctx = await harness()
|
||||
const lifecycle: string[] = []
|
||||
ctx.on('session/created', () => void lifecycle.push('session'))
|
||||
ctx.on('agent/created', () => void lifecycle.push('agent'))
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('guarded-publication'),
|
||||
sessionId: SessionId('guarded-publication-s'),
|
||||
const pendingController = new AbortController()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const pending = ctx.agents.create({
|
||||
agentId: AgentId('signal-pending'),
|
||||
sessionId: SessionId('signal-pending-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
const agent = agentCtx.agent!
|
||||
expect(() => agentCtx.agents.enter(agent)).toThrow(/reserved for unpublished creation/)
|
||||
expect(() => agentCtx.agents.register(agent)).toThrow(/reserved for unpublished creation/)
|
||||
expect(() => agentCtx.sessions.enter(agent.session)).toThrow(/reserved for unpublished creation/)
|
||||
expect(() => agentCtx.sessions.prepare(agent.session.id)).toThrow(/reserved for unpublished creation/)
|
||||
expect(() => agentCtx.sessions.create(agent.session.id)).toThrow(/reserved for unpublished creation/)
|
||||
expect(lifecycle).toEqual([])
|
||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||
expect(ctx.sessions.get(agent.session.id)).toBeUndefined()
|
||||
signal: pendingController.signal,
|
||||
setup: async () => {
|
||||
setupStarted.resolve(undefined)
|
||||
await new Promise<never>(() => {})
|
||||
},
|
||||
})
|
||||
await setupStarted.promise
|
||||
pendingController.abort(new Error('cancel pending creation'))
|
||||
await expect(pending).rejects.toThrow('cancel pending creation')
|
||||
expect(ctx.agents.get(AgentId('signal-pending'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('signal-pending-s'))).toBeUndefined()
|
||||
|
||||
expect(lifecycle).toEqual(['session', 'agent'])
|
||||
expect(ctx.agents.get(handle.agent.id)).toBe(handle.agent)
|
||||
expect(ctx.sessions.get(handle.agent.session.id)).toBe(handle.agent.session)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('structurally rejects every driving verb during setup', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('no-drive'),
|
||||
sessionId: SessionId('no-drive-s'),
|
||||
const liveController = new AbortController()
|
||||
const live = await ctx.agents.create({
|
||||
agentId: AgentId('signal-live'),
|
||||
sessionId: SessionId('signal-live-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
const agent = agentCtx.agent!
|
||||
// Even JavaScript or a cast to the exported concrete class cannot name
|
||||
// a public start method. Driver startup is behind a module-private
|
||||
// symbol used only by AgentLoop after rollback-covered publication.
|
||||
expect(Reflect.get(agent as ReactLoopAgent, 'start')).toBeUndefined()
|
||||
expect(Reflect.get(concreteAgentModule, 'enableAgentDrive')).toBeUndefined()
|
||||
expect(Reflect.get(concreteAgentModule, 'startAgentDriver')).toBeUndefined()
|
||||
expect(() => concreteAgentModule.prepareReactLoopAgent(
|
||||
agentCtx, agent.id, agent.options, agent.session,
|
||||
)).toThrow(/already has a concrete agent driver/)
|
||||
expect(Reflect.get(agent as ReactLoopAgent, 'inbox')).toBeUndefined()
|
||||
expect(() => { agent.send(text('queued too soon')) }).toThrow(/cannot send before creation setup completes/)
|
||||
expect(() => { agent.steer(text('steered too soon')) }).toThrow(/cannot steer before creation setup completes/)
|
||||
expect(() => { agent.inject(text('injected too soon')) }).toThrow(/cannot inject before creation setup completes/)
|
||||
expect(() => { agent.cancel('cancel too soon') }).toThrow(/cannot cancel before creation setup completes/)
|
||||
expect(agent.session.events).toEqual([])
|
||||
},
|
||||
signal: liveController.signal,
|
||||
})
|
||||
expect(handle.agent.session.events).toEqual([])
|
||||
await handle.dispose()
|
||||
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()
|
||||
})
|
||||
|
||||
it('owner unload aborts a pending setup and publishes nothing', async () => {
|
||||
@@ -347,16 +334,11 @@ describe('agent scope lifecycle', () => {
|
||||
await setupStarted.promise
|
||||
|
||||
await loopFiber.dispose()
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await expect(creating).rejects.toThrow(/agent loop is not active/)
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('factory-setup-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined()
|
||||
|
||||
// Factory unload itself reached the reservation-release boundary.
|
||||
const agentReservation = ctx.agents.reserve(AgentId('factory-setup-race'))
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-setup-race-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
gate.resolve(undefined)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -377,16 +359,12 @@ describe('agent scope lifecycle', () => {
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: () => { setupCalls += 1 },
|
||||
})
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await expect(creating).rejects.toThrow(/agent loop is not active/)
|
||||
await loopFiber.dispose()
|
||||
expect(setupCalls).toBe(0)
|
||||
expect(ctx.agents.get(AgentId('factory-scope-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined()
|
||||
|
||||
const agentReservation = ctx.agents.reserve(AgentId('factory-scope-race'))
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-scope-race-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -444,14 +422,14 @@ describe('agent scope lifecycle', () => {
|
||||
})
|
||||
|
||||
expect(() => ctx.agentLoop.create(AgentId('config-scope-race'), { model: 'mock' }))
|
||||
.toThrow(/owner disposed during setup/)
|
||||
.toThrow(/agent loop is not active/)
|
||||
await loopFiber.dispose()
|
||||
expect(ctx.agents.get(AgentId('config-scope-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('synchronous create releases both reservations when session preparation fails', async () => {
|
||||
it('synchronous create leaves no lifecycle state when session preparation fails', async () => {
|
||||
const ctx = await harness()
|
||||
const id = AgentId('config-prepare-failure')
|
||||
|
||||
@@ -463,44 +441,7 @@ describe('agent scope lifecycle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('turns owner disposal from the caller association getter into a rollback boundary', async () => {
|
||||
const ctx = await harness()
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
let getterCalls = 0
|
||||
const creationStarted = Promise.withResolvers<undefined>()
|
||||
const owner = ctx.plugin(Object.assign((inner: Context) => {
|
||||
Object.defineProperty(inner, 'agent', {
|
||||
configurable: true,
|
||||
get() {
|
||||
getterCalls += 1
|
||||
void inner.fiber.dispose()
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('association-dispose'),
|
||||
sessionId: SessionId('association-dispose-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
creationStarted.resolve(undefined)
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await creationStarted.promise
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner
|
||||
expect(getterCalls).toBe(1)
|
||||
expect(ctx.agents.get(AgentId('association-dispose'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('association-dispose-s'))).toBeUndefined()
|
||||
const replacement = await ctx.agents.create({
|
||||
agentId: AgentId('association-dispose'),
|
||||
sessionId: SessionId('association-dispose-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
await replacement.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('factory unload awaits reservations when reentrant scope preparation throws', async () => {
|
||||
it('factory unload awaits provisional cleanup when scope preparation throws', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
let triggered = false
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
@@ -519,36 +460,6 @@ describe('agent scope lifecycle', () => {
|
||||
expect(ctx.agents.get(AgentId('factory-scope-throw'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
|
||||
|
||||
const agentReservation = ctx.agents.reserve(AgentId('factory-scope-throw'))
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-scope-throw-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('factory unload during session preparation awaits create reservation release', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
let unloading!: Promise<void>
|
||||
const meta = {
|
||||
get cwd() {
|
||||
unloading = loopFiber.dispose()
|
||||
return '/factory-unload'
|
||||
},
|
||||
}
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
agentId: AgentId('factory-prepare-race'),
|
||||
sessionId: SessionId('factory-prepare-race-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
meta,
|
||||
})
|
||||
await unloading
|
||||
await expect(creating).rejects.toThrow('agent loop is not active')
|
||||
|
||||
const agentReservation = ctx.agents.reserve(AgentId('factory-prepare-race'))
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-prepare-race-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -566,14 +477,10 @@ describe('agent scope lifecycle', () => {
|
||||
expect(handle.agent.status).toBe('disposed')
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-live-s'))).toBeUndefined()
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.ownerLifecycle(${agentId})`)).toEqual([])
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([])
|
||||
// The consumer handle shares the provider's completed quiescence boundary.
|
||||
await handle.dispose()
|
||||
|
||||
const agentReservation = ctx.agents.reserve(agentId)
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-live-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await expect(loop.createAgent(ctx, {
|
||||
agentId: AgentId('factory-inactive'),
|
||||
sessionId: SessionId('factory-inactive-s'),
|
||||
@@ -647,7 +554,7 @@ describe('agent scope lifecycle', () => {
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await expect(creating).rejects.toThrow(/lifecycle disposed/)
|
||||
await owner.dispose()
|
||||
expect(lifecycle).toEqual([
|
||||
'session-created:dispose',
|
||||
@@ -696,7 +603,7 @@ describe('agent scope lifecycle', () => {
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await expect(creating).rejects.toThrow(/lifecycle disposed/)
|
||||
await owner.dispose()
|
||||
expect(lifecycle).toEqual([
|
||||
'session-created',
|
||||
@@ -711,52 +618,6 @@ describe('agent scope lifecycle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rechecks owner liveness after carrier capture before the first creation edge', async () => {
|
||||
const ctx = await harness()
|
||||
let ownerCtx!: Context
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] }))
|
||||
const agentId = AgentId('carrier-owner-race')
|
||||
const sessionId = SessionId('carrier-owner-race-s')
|
||||
const lifecycle: string[] = []
|
||||
let filterReads = 0
|
||||
ctx.on('session/created', (session) => {
|
||||
if (session.id === sessionId) lifecycle.push('session-created')
|
||||
})
|
||||
ctx.on('session/disposed', (session) => {
|
||||
if (session.id === sessionId) lifecycle.push('session-disposed')
|
||||
})
|
||||
ctx.on('agent/created', (agent) => {
|
||||
if (agent.id === agentId) lifecycle.push('agent-created')
|
||||
})
|
||||
ctx.on('agent/disposed', (agent) => {
|
||||
if (agent.id === agentId) lifecycle.push('agent-disposed')
|
||||
})
|
||||
|
||||
const creating = ownerCtx.agents.create({
|
||||
agentId,
|
||||
sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
setup(agentCtx) {
|
||||
Object.defineProperty(agentCtx.agent!.session, Context.filter, {
|
||||
configurable: true,
|
||||
get() {
|
||||
filterReads += 1
|
||||
void owner.dispose()
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner.dispose()
|
||||
expect(filterReads).toBe(1)
|
||||
expect(lifecycle).toEqual([])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rechecks caller liveness after creation listeners before unlocking the driver', async () => {
|
||||
const ctx = await harness()
|
||||
const starts: string[] = []
|
||||
@@ -817,7 +678,7 @@ describe('agent scope lifecycle', () => {
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await expect(creating).rejects.toThrow(/lifecycle disposed/)
|
||||
await owner.dispose()
|
||||
expect(announced.status).toBe('disposed')
|
||||
expect(statuses).toEqual(['disposed'])
|
||||
@@ -853,7 +714,7 @@ describe('agent scope lifecycle', () => {
|
||||
await retry.dispose()
|
||||
})
|
||||
|
||||
it('rejects an exotic seed before publishing either reserved identity', async () => {
|
||||
it('rejects an exotic durable seed before publishing either identity', async () => {
|
||||
const ctx = await harness()
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => { published.push('session') })
|
||||
@@ -966,29 +827,6 @@ describe('agent scope lifecycle', () => {
|
||||
expect(heard).toEqual(['a1:2'])
|
||||
})
|
||||
|
||||
it('a listener may drive the agent through its declared `this` (the carrier is method-transparent)', async () => {
|
||||
// ds-review-bot regression: agent/* listeners are typed
|
||||
// `this: Scoped<Agent>`, and ReactLoopAgent's send/steer/cancel read the
|
||||
// native-private #carrier — a proxy-receiver carrier made
|
||||
// `this.send(...)` throw TypeError. The carrier binds methods to the real
|
||||
// agent, so driving through the event `this` is a working supported shape.
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
let followUpSent = false
|
||||
ctx.on('agent/session-start', function (this: Agent) {
|
||||
// Deliberately through `this`, not the args subject.
|
||||
this.send(text('driven through this'))
|
||||
followUpSent = true
|
||||
})
|
||||
const second = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
|
||||
expect(followUpSent).toBe(true)
|
||||
await second.whenIdle()
|
||||
// The send actually reached the loop: the prompt ran a turn.
|
||||
expect(second.session.events.some(e => e.type === 'turn/start')).toBe(true)
|
||||
await agent.whenIdle()
|
||||
})
|
||||
|
||||
it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => {
|
||||
const ctx = await harness()
|
||||
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||
@@ -1044,18 +882,18 @@ describe('agent scope lifecycle', () => {
|
||||
await unload
|
||||
})
|
||||
|
||||
it('successful handle disposal retires its caller ownership sentinel', async () => {
|
||||
it('successful handle disposal retires its caller ownership effect', async () => {
|
||||
const ctx = await harness()
|
||||
const agentId = AgentId('retired-owner-sentinel')
|
||||
const agentId = AgentId('retired-owner-effect')
|
||||
const handle = await ctx.agents.create({
|
||||
agentId,
|
||||
sessionId: SessionId('retired-owner-sentinel-s'),
|
||||
sessionId: SessionId('retired-owner-effect-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.ownerLifecycle(${agentId})`)
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${agentId})`)
|
||||
await handle.dispose()
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.ownerLifecycle(${agentId})`)).toEqual([])
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -1091,13 +929,13 @@ describe('agent scope lifecycle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('retains both identity reservations until scope teardown reaches quiescence', async () => {
|
||||
it('reopens ids after detach while the prior private scope finishes quiescing', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
const sessionDisposed = Promise.withResolvers<undefined>()
|
||||
const agentId = AgentId('quiescent-reservation')
|
||||
const sessionId = SessionId('quiescent-reservation-s')
|
||||
const agentId = AgentId('quiescent-reuse')
|
||||
const sessionId = SessionId('quiescent-reuse-s')
|
||||
ctx.on('session/disposed', (session) => {
|
||||
if (session.id === sessionId) sessionDisposed.resolve(undefined)
|
||||
})
|
||||
@@ -1117,12 +955,12 @@ describe('agent scope lifecycle', () => {
|
||||
await Promise.all([sessionDisposed.promise, cleanupStarted.promise])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
await expect(ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } }))
|
||||
.rejects.toThrow(/reserved/)
|
||||
const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } })
|
||||
expect(ctx.agents.get(agentId)).toBe(replacement.agent)
|
||||
expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session)
|
||||
|
||||
gate.resolve(undefined)
|
||||
await disposing
|
||||
const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } })
|
||||
await replacement.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -156,12 +156,9 @@ describe('agent/turn-stop', () => {
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('fails throwing and malformed terminal policies closed while the driver survives', async () => {
|
||||
it('fails a throwing terminal policy closed while the driver survives', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('throwing policy'),
|
||||
textResponse('malformed continue policy'),
|
||||
textResponse('malformed false policy'),
|
||||
textResponse('malformed null policy'),
|
||||
textResponse('healthy later turn'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -179,21 +176,10 @@ describe('agent/turn-stop', () => {
|
||||
await send(agent, 'first')
|
||||
disposeThrowing()
|
||||
|
||||
for (const [index, malformed] of [
|
||||
{ action: 'continue' },
|
||||
false,
|
||||
null,
|
||||
].entries()) {
|
||||
const disposeMalformed = agent.ctx.on('agent/turn-stop', () => malformed as unknown as ContinuationStop)
|
||||
await send(agent, `malformed ${index}`)
|
||||
disposeMalformed()
|
||||
}
|
||||
|
||||
await send(agent, 'healthy')
|
||||
|
||||
expect(reasons.map(reason => reason.kind)).toEqual(['error', 'error', 'error', 'error', 'completed'])
|
||||
expect(reasons.map(reason => reason.kind)).toEqual(['error', 'completed'])
|
||||
expect(errors).toContain('terminal policy exploded')
|
||||
expect(errors).toContain("agent/turn-stop returned an invalid result; expected { action: 'stop' } or undefined")
|
||||
expect(adapter.requests).toHaveLength(5)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user