refactor(agent): unify agent-scoped event signatures as payload objects

All agent/* and agent-loop/config-start-failed events take one payload
object carrying the agent subject; waterfall/serial payloads require a
signal and keep next as the final argument. PreStepContext and
RequestFailureContext are unfolded into payloads and retired.
goal/changed follows the same shape so agentEvents keeps its listener
error containment. ReactLoopAgent builds its scope carrier once in the
constructor. Regenerates scope resolvers, tool-cordis api catalog, and
docs catalogs; updates all affected listeners, tests, and the
core-data-structures docs (en + zh).
This commit is contained in:
_Kerman
2026-08-06 12:13:14 +08:00
parent bb53e25ed0
commit ccebba2349
91 changed files with 574 additions and 618 deletions

View File

@@ -17,25 +17,38 @@ type Params<F> = F extends (...args: infer P) => unknown ? P : never
type Return<F> = F extends (...args: never[]) => infer R ? R : never
/**
* The event names whose subject is an agent: handler parameters start with an
* `Agent` AND the handler declares a `Scoped<Agent>` `this` (the scope-carrier
* contract). The `this` check keeps accidental first-parameter-happens-to-be-
* an-Agent events (or zero-arg events, whose parameter tuple would satisfy a
* bare rest-tuple check via callability) out of the fused-dispatch surface.
* The event names whose subject is an agent: the handler's first parameter is
* a payload object carrying the `agent` subject AND the handler declares a
* `Scoped<Agent>` `this` (the scope-carrier contract). The `this` check keeps
* accidental payload-happens-to-carry-an-Agent events (or zero-arg events,
* whose parameter tuple would satisfy a bare rest-tuple check via callability)
* out of the fused-dispatch surface.
*/
export type AgentSubjectEvent = {
[K in keyof Events]: Events[K] extends (this: Scoped<Agent>, ...args: infer P) => unknown
? P extends [Agent, ...unknown[]] ? K : never
? P extends [infer Payload, ...unknown[]]
? Payload extends { agent: Agent } ? K : never
: never
: never
}[keyof Events]
/** The event arguments AFTER the injected agent subject. */
type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [Agent, ...infer R] ? R : never
/** The full payload object of one agent-subject event. */
type PayloadOf<K extends AgentSubjectEvent> = Params<Events[K]> extends [infer Payload, ...unknown[]] ? Payload : never
/** The event arguments AFTER the payload: the waterfall `next` when present. */
type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [unknown, ...infer R] ? R : never
/**
* The payload as emit-side callers pass it: the full payload minus the agent
* field, which the fused dispatcher injects so subject and scope key cannot
* diverge.
*/
type PayloadRest<K extends AgentSubjectEvent> = Omit<PayloadOf<K> & object, 'agent'>
/**
* The fused dispatcher {@link agentEvents} returns: each method dispatches the
* named agent-subject event with the agent's scope carrier as `thisArg` and
* the agent itself injected as the first event argument.
* the agent itself injected into the payload.
*/
export interface AgentEventDispatch {
/**
@@ -44,30 +57,35 @@ export interface AgentEventDispatch {
* contained per listener, so a notification cannot veto lifecycle progress
* or starve a later observer.
* @param name - the agent-subject event to emit.
* @param rest - the event's arguments after the injected agent.
* @param payload - the event's payload fields; `agent` is injected.
*/
emit<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): void
emit<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>): void
/**
* Awaited in-order dispatch (Cordis `serial`) in the agent's scope.
* @param name - the agent-subject event to dispatch.
* @param rest - the event's arguments after the injected agent.
* @param payload - the event's payload fields; `agent` is injected.
* @returns the serial chain's result (the first bail value, if any).
*/
serial<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>>
serial<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>): Promise<Awaited<Return<Events[K]>>>
/**
* Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The
* declared event parameters already end with the `next` callback, so `rest`
* is exactly the event's arguments after the injected agent — the final
* element being the innermost `next` (the default the listener chain wraps).
* is exactly the event's arguments after the payload — the final element
* being the innermost `next` (the default the listener chain wraps).
* @param name - the agent-subject event to dispatch.
* @param rest - the event's arguments after the injected agent.
* @param payload - the event's payload fields; `agent` is injected.
* @param rest - the event's arguments after the payload (the `next` callback).
* @returns the waterfall's composed result.
*/
waterfall<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Return<Events[K]>
waterfall<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>, ...rest: Tail<K>): Return<Events[K]>
}
/**
* Return the fused scope carrier for one agent subject.
* Build the fused scope carrier for one agent subject.
*
* The carrier is a stateless routing object; callers that dispatch repeatedly
* for the same agent (the loop driver) build it once in the agent's
* constructor and reuse it, so hot-path dispatches never allocate.
* @param agent - the subject agent and scope key.
* @returns the carrier passed as the event dispatcher `this` value.
*/
@@ -84,17 +102,21 @@ export function agentCarrier(agent: Agent): Scoped<Agent> {
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
const carrier = agentCarrier(agent)
// The ordinary dispatch methods forward through Cordis' variadic mixins. The
// fused (carrier, name, agent, ...rest) tuple is provably a valid argument
// fused (carrier, name, payload, ...rest) tuple is provably a valid argument
// list for the matching thisArg overload, but TypeScript cannot relate the
// generic Tail<K> spread back to that overload's conditional parameter
// tuple — hence one contained, shape-preserving cast per method.
const fused = <K extends AgentSubjectEvent>(payload: PayloadRest<K>): PayloadOf<K> =>
// The dispatcher owns the subject injection; callers pass PayloadRest, so
// the fused record is exactly the declared payload.
({ agent, ...payload } as PayloadOf<K>)
return {
emit(name, ...rest) {
emit(name, payload) {
// Cordis emit invokes callbacks through Array.map: one synchronous throw
// starves later listeners, and returned promises are discarded. Agent
// notifications are non-vetoing, so resolve the same filtered callback
// set ourselves and contain both failure modes independently.
const args: unknown[] = [carrier, name, agent, ...rest]
const args: unknown[] = [carrier, name, fused(payload)]
const callbacks = ctx.events.dispatch('emit', args)
for (const callback of callbacks) {
try {
@@ -107,15 +129,15 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
}
}
},
async serial(name, ...rest) {
async serial(name, payload) {
// oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never>
return await serial(carrier, name, agent, ...rest)
return await serial(carrier, name, fused(payload))
},
waterfall(name, ...rest) {
waterfall(name, payload, ...rest) {
// oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => never
return waterfall(carrier, name, agent, ...rest)
return waterfall(carrier, name, fused(payload), ...rest)
},
}
}
@@ -125,15 +147,15 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
* @param ctx - the context to dispatch through.
* @param agent - the subject agent and scope key.
* @param name - the agent-subject event to emit.
* @param rest - the event arguments after the injected agent.
* @param payload - the event's payload fields; `agent` is injected.
*/
export function emitAgentEvent<K extends AgentSubjectEvent>(
ctx: Context,
agent: Agent,
name: K,
...rest: Tail<K>
payload: PayloadRest<K>,
): void {
agentEvents(ctx, agent).emit(name, ...rest)
agentEvents(ctx, agent).emit(name, payload)
}
/**

View File

@@ -498,7 +498,7 @@ export class AgentRegistry extends Service {
/** Emit the paired disposal edge through the entry's stable carrier. */
private emitDisposed(entry: AgentEntry): void {
const args: unknown[] = [entry.carrier, 'agent/disposed', entry.agent]
const args: unknown[] = [entry.carrier, 'agent/disposed', { agent: entry.agent }]
for (const callback of this.ctx.events.dispatch('emit', args)) {
try {
const returned: unknown = callback(...args)
@@ -530,7 +530,7 @@ export class AgentRegistry extends Service {
// lifecycle edge; detach still pairs a partially delivered first edge.
entry.announcing = true
entry.announced = true
const args: unknown[] = [entry.carrier, 'agent/created', entry.agent]
const args: unknown[] = [entry.carrier, 'agent/created', { agent: entry.agent }]
try {
for (const callback of this.ctx.events.dispatch('emit', args)) {
// A synchronous creation failure vetoes publication and rolls back.

View File

@@ -14,7 +14,7 @@ export const inject = ['invariants']
/** Install the agent contribution into its child registration fiber. */
const install: InvariantInstaller = (ctx, fail) => {
const lastStatus = new WeakMap<Agent, AgentStatus>()
ctx.on('agent/status', (agent, status) => {
ctx.on('agent/status', ({ agent, status }) => {
const previous = lastStatus.get(agent)
if (previous === status) {
fail(`agent/status repeated ${status} (no-op transition)`)

View File

@@ -53,7 +53,7 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR
})
const disposeRequest = agentCtx.on(
'agent/request',
async (_agent, _turn, _step, _signal, next): Promise<LlmCallConfig> => {
async (_payload, next): Promise<LlmCallConfig> => {
const resolved = await next()
const selected = target.assembled
if (selected === undefined) return resolved

View File

@@ -48,35 +48,11 @@ export interface CancelOptions {
*/
export type AgentStatus = 'idle' | 'running'
/** Coordinates and cancellation for a proposed step. */
export interface PreStepContext {
/** Turn that will own the step. */
readonly turn: number
/** Step proposed by the loop. */
readonly step: number
/** Current turn cancellation signal. */
readonly signal: AbortSignal
}
/** Whether and with which messages the loop enters a proposed step. */
export type PreStepDecision =
| { kind: 'reject' }
| { kind: 'enter'; messages: UserMessage[] }
/** One failed model-request attempt presented to recovery listeners. */
export interface RequestFailureContext {
/** Turn containing the failed request. */
readonly turn: number
/** Step containing the failed request attempt. */
readonly step: number
/** Provider selected for the failed request. */
readonly provider: string
/** Serializable facts normalized at the final adapter boundary. */
readonly failure: LlmFailure
/** Policy of the adapter registration that served the failed request. */
readonly retryPolicy: ResolvedRetryPolicy | undefined
}
/** Action returned by a listener that owns model-request recovery. */
export type RequestErrorAction = { kind: 'retry' } | undefined
@@ -171,105 +147,112 @@ declare module 'cordis' {
* Synchronous listener failure vetoes publication, while returned-promise
* rejection is reported. Detach requested during dispatch waits until every
* creation listener has observed the stable entry.
* @param agent - the newly registered agent with its live session and completed setup.
* @param payload.agent - the newly registered agent with its live session and completed setup.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/created'(this: Scoped<Agent>, agent: Agent): void
'agent/created'(this: Scoped<Agent>, payload: { agent: Agent }): void
/**
* An agent left the registry; AgentLoop emits this after driver quiescence
* and scoped-registration unwind, but before session detachment. Custom
* registry users own their driver-ordering contract.
* @param agent - the exact agent removed from the registry.
* @param payload.agent - the exact agent removed from the registry.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
'agent/disposed'(this: Scoped<Agent>, payload: { agent: Agent }): void
/**
* Agent status changed (`idle` ⇄ `running`). A waking delivery enters
* `running` synchronously after reserving cancellation; `idle` means no
* driver remains scheduled or active.
* @param agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination).
* @param payload.agent - the agent whose status flipped.
* @param payload.status - the status just entered (the transition's destination).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
'agent/status'(this: Scoped<Agent>, payload: { agent: Agent; status: AgentStatus }): void
/**
* One message entered the live inbox.
* @param agent - the agent whose inbox changed.
* @param event - the inserted message.
* @param payload.agent - the agent whose inbox changed.
* @param payload.message - the inserted message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/inserted'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void
'agent/inbox/inserted'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void
/**
* One message left the inbox inside its open turn. If the proposed step
* is rejected, the claimed message ends here: it is neither discarded nor
* re-emitted as a user/message, and the turn closes without a step.
* @param agent - the agent whose inbox changed.
* @param event - the claimed message and owning turn.
* @param payload.agent - the agent whose inbox changed.
* @param payload.message - the claimed message.
* @param payload.turn - the owning turn.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/claimed'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage; turn: number }): void
'agent/inbox/claimed'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; turn: number }): void
/**
* One message was discarded from the live inbox.
* @param agent - the agent whose inbox changed.
* @param event - the discarded message.
* @param payload.agent - the agent whose inbox changed.
* @param payload.message - the discarded message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/discarded'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void
'agent/inbox/discarded'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void
// ---- session lifecycle (emit) ----
/**
* The session lifecycle began, once before the first turn. Use
* `agent.inject()` to seed model-facing context. This is a notification, not
* a veto; disposal requested by a lifecycle owner is rechecked before the
* driver starts.
* @param agent - the agent whose session lifecycle began.
* @param source - why the session started (fresh startup, resume, …).
* @param payload.agent - the agent whose session lifecycle began.
* @param payload.source - why the session started (fresh startup, resume, …).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
'agent/session-start'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource }): void
// ---- the machine's extension seams ----
/**
* Reject a proposed step or replace the messages that enter it. Calling
* `next()` preserves the current messages.
* @param agent - the agent proposing the step.
* @param messages - messages removed from the inbox for this step.
* @param context - proposed turn and step coordinates plus cancellation.
* @param payload.agent - the agent proposing the step.
* @param payload.messages - messages removed from the inbox for this step.
* @param payload.turn - the turn that will own the step.
* @param payload.step - the step proposed by the loop.
* @param payload.signal - the current turn's cancellation signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
/**
* Replace the frozen call configuration. `await next()` yields the config
* the machine would use (agent options on the first request, the logged
* header afterwards); return a replacement to switch. Model-visible
* content must use logged channels; this seam cannot mutate messages.
* @param agent - the agent making the model call.
* @param turn - the open turn number.
* @param step - the step whose request this is.
* @param signal - the current turn's explicit abort signal.
* @param payload.agent - the agent making the model call.
* @param payload.turn - the open turn number.
* @param payload.step - the step whose request this is.
* @param payload.signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
'agent/request'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
/**
* Handle one failed model-request attempt before the loop retries or closes
* its step. A listener returns `{ kind: 'retry' }` without calling `next()`
* when it owns recovery, or calls `next()` to delegate. The default
* `undefined` leaves the failure terminal.
* @param agent - the agent whose request failed.
* @param context - request coordinates, provider, normalized failure, and serving policy.
* @param signal - the turn abort signal.
* @param payload.agent - the agent whose request failed.
* @param payload.turn - the turn containing the failed request.
* @param payload.step - the step containing the failed request attempt.
* @param payload.provider - the provider selected for the failed request.
* @param payload.failure - serializable facts normalized at the final adapter boundary.
* @param payload.retryPolicy - the policy of the adapter registration that served the failed request.
* @param payload.signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/request-error'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
'agent/request-error'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
/**
* The turn is about to close: the model owes no response (no live tool
* calls, no fresh steering). Awaited before the boundary commits — a
@@ -281,25 +264,25 @@ declare module 'cordis' {
* never short-circuits already-submitted next-step work: same-step
* `additionalContexts` or racing steering still runs, and the turn
* closes only when that inbox drains.
* @param agent - the agent whose turn is at its stop boundary.
* @param turn - the turn about to close.
* @param signal - the current turn's explicit abort signal.
* @param payload.agent - the agent whose turn is at its stop boundary.
* @param payload.turn - the turn about to close.
* @param payload.signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode serial
*/
'agent/turn-stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void
'agent/turn-stopping'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise<void> | void
// ---- error notifications (emit) ----
/**
* A step or turn errored. The machine reports a failure here even when
* the error has no in-turn position for a durable record.
* @param agent - the agent whose turn errored.
* @param turn - the turn in which the failure surfaced.
* @param step - the step at which the failure surfaced.
* @param error - the failure, verbatim.
* @param payload.agent - the agent whose turn errored.
* @param payload.turn - the turn in which the failure surfaced.
* @param payload.step - the step at which the failure surfaced.
* @param payload.error - the failure, verbatim.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void
'agent/error'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; error: unknown }): void
}
}

View File

@@ -145,8 +145,8 @@ describe('AgentRegistry', () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
const agent = stubAgent('a1')
const dispose = ctx.agents.register(agent)
@@ -195,9 +195,9 @@ describe('AgentRegistry', () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/created', () => { throw new Error('creation veto') })
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto')
expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined()
@@ -213,7 +213,7 @@ describe('AgentRegistry', () => {
ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never)
ctx.on('agent/disposed', () => { throw new Error('disposed sync') })
ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never)
ctx.on('agent/disposed', agent => void heard.push(agent.id))
ctx.on('agent/disposed', ({ agent }) => void heard.push(agent.id))
const dispose = ctx.agents.register(stubAgent('contained'))
await Promise.resolve()
@@ -232,8 +232,8 @@ describe('AgentRegistry', () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
const first = stubAgent('split')
const detachFirst = ctx.agents.enter(first, undefined)
@@ -280,9 +280,9 @@ describe('agentEvents()', () => {
const agent = stubAgent('event')
ctx.on('agent/status', () => { throw new Error('sync listener') })
ctx.on('agent/status', () => Promise.reject(new Error('async listener')) as never)
ctx.on('agent/status', (_agent, status) => void heard.push(status))
ctx.on('agent/status', ({ status }) => void heard.push(status))
agentEvents(ctx, agent).emit('agent/status', 'running')
agentEvents(ctx, agent).emit('agent/status', { status: 'running' })
await Promise.resolve()
expect(heard).toEqual(['running'])
expect(warnings).toEqual([
@@ -296,12 +296,12 @@ describe('agentEvents()', () => {
const agent = stubAgent('serial-event')
const signal = new AbortController().signal
const heard: Array<{ agent: Agent; turn: number; signal: AbortSignal }> = []
ctx.on('agent/turn-stopping', async (subject, turn, receivedSignal) => {
ctx.on('agent/turn-stopping', async ({ agent: subject, turn, signal: receivedSignal }) => {
await Promise.resolve()
heard.push({ agent: subject, turn, signal: receivedSignal })
})
await agentEvents(ctx, agent).serial('agent/turn-stopping', 3, signal)
await agentEvents(ctx, agent).serial('agent/turn-stopping', { turn: 3, signal })
expect(heard).toEqual([{ agent, turn: 3, signal }])
})

View File

@@ -21,17 +21,17 @@ describe('agent status invariants', () => {
const ctx = await setup()
const agent = mockAgent('a1')
expect(() => {
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'idle' })
ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' })
ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'idle' })
}).not.toThrow()
})
it('rejects a no-op transition', async () => {
const ctx = await setup()
const agent = mockAgent('a3')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') })
ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' })
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' }) })
.toThrow(/no-op transition/)
})
@@ -39,7 +39,7 @@ describe('agent status invariants', () => {
const ctx = await setup()
const a = mockAgent('a5')
const b = mockAgent('b5')
ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running')
expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow()
ctx.emit(scopeTarget(a, a), 'agent/status', { agent: a, status: 'running' })
expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', { agent: b, status: 'running' }) }).not.toThrow()
})
})

View File

@@ -21,7 +21,7 @@ describe('installAgentLlmTarget()', () => {
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toBe(seed)
target.current = {
@@ -32,7 +32,7 @@ describe('installAgentLlmTarget()', () => {
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
target.current = { provider: 'beta', model: 'b1' }
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toEqual({
provider: 'alpha',
model: 'a1',
@@ -48,13 +48,13 @@ describe('installAgentLlmTarget()', () => {
temperature: 0.2,
}
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 1, signal, () => Promise.resolve(inherited),
'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(inherited),
)).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
dispose()
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 2, 0, signal, () => Promise.resolve(seed),
'agent/request', { turn: 2, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toBe(seed)
await ctx.fiber.dispose()
})