Merge origin/master into worktree/explicit-turn-signal
This commit is contained in:
265
packages/core/agent/tests/agent-initiator.spec.ts
Normal file
265
packages/core/agent/tests/agent-initiator.spec.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
function agent(id: string): Agent {
|
||||
return { id: SessionId(id) } as Agent
|
||||
}
|
||||
|
||||
async function harness(): Promise<{
|
||||
ctx: Context
|
||||
service: AgentRegistry
|
||||
dispose: () => Promise<void>
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(AgentRegistry)
|
||||
return {
|
||||
ctx,
|
||||
service: ctx.agents,
|
||||
dispose: fiber.dispose,
|
||||
}
|
||||
}
|
||||
|
||||
/** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */
|
||||
async function promptly<T>(task: Promise<T>): Promise<T> {
|
||||
const timeout = Promise.withResolvers<never>()
|
||||
const timer = setTimeout(() => { timeout.reject(new Error('initiator teardown did not settle promptly')) }, 1000)
|
||||
try {
|
||||
return await Promise.race([task, timeout.promise])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentRegistry initiator scope', () => {
|
||||
it('reports an absent initiator and requires an active boundary', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
expect(() => service.requireInitiator()).toThrow('no initiating agent is active')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('preserves exact synchronous and Promise return identities across await', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const initiator = agent('identity')
|
||||
const value = { result: true }
|
||||
expect(service.withInitiator(initiator, () => {
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
return value
|
||||
})).toBe(value)
|
||||
|
||||
const promise = service.withInitiator(initiator, async () => {
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
await Promise.resolve()
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
return value
|
||||
})
|
||||
expect(service.withInitiator(initiator, () => promise)).toBe(promise)
|
||||
await expect(promise).resolves.toBe(value)
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('tracks a branded Promise without calling its overridable then property', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const initiator = agent('overridden-then')
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
void Object.defineProperty(release.promise, 'then', {
|
||||
value: () => { throw new Error('overridden then called') },
|
||||
})
|
||||
|
||||
const pending = service.withInitiator(initiator, () => release.promise)
|
||||
expect(pending).toBe(release.promise)
|
||||
|
||||
let disposed = false
|
||||
const disposal = dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
|
||||
release.resolve(true)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void Promise.prototype.then.call(pending, resolve, reject)
|
||||
})
|
||||
await disposal
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves a settled branded Promise when its species blocks observer construction', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const initiator = agent('invalid-species')
|
||||
const promise = Promise.resolve()
|
||||
const constructor = {}
|
||||
Object.defineProperty(constructor, Symbol.species, {
|
||||
get: () => { throw new Error('invalid species') },
|
||||
})
|
||||
void Object.defineProperty(promise, 'constructor', { value: constructor })
|
||||
|
||||
expect(service.withInitiator(initiator, () => promise)).toBe(promise)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('isolates overlapping initiators', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const a = agent('a')
|
||||
const b = agent('b')
|
||||
const bothStarted = Promise.withResolvers<boolean>()
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
let starts = 0
|
||||
const run = (initiator: Agent): Promise<void> => service.withInitiator(initiator, async () => {
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
starts += 1
|
||||
if (starts === 2) bothStarted.resolve(true)
|
||||
await release.promise
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
})
|
||||
|
||||
const pending = [run(a), run(b)]
|
||||
await bothStarted.promise
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
release.resolve(true)
|
||||
await Promise.all(pending)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('restores nested and explicitly cleared boundaries', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const parent = agent('parent')
|
||||
const child = agent('child')
|
||||
|
||||
service.withInitiator(parent, () => {
|
||||
expect(service.requireInitiator()).toBe(parent)
|
||||
service.withInitiator(child, () => { expect(service.requireInitiator()).toBe(child) })
|
||||
expect(service.requireInitiator()).toBe(parent)
|
||||
service.withoutInitiator(() => {
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
expect(() => service.requireInitiator()).toThrow('no initiating agent is active')
|
||||
})
|
||||
expect(service.requireInitiator()).toBe(parent)
|
||||
})
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('restores the parent after synchronous throws and rejected operations', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const parent = agent('parent')
|
||||
const child = agent('child')
|
||||
const syncError = new Error('sync failure')
|
||||
const asyncError = new Error('async failure')
|
||||
|
||||
service.withInitiator(parent, () => {
|
||||
expect(() => service.withInitiator(child, () => { throw syncError })).toThrow(syncError)
|
||||
expect(service.requireInitiator()).toBe(parent)
|
||||
})
|
||||
await expect(service.withInitiator(child, async () => {
|
||||
await Promise.resolve()
|
||||
throw asyncError
|
||||
})).rejects.toBe(asyncError)
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('stops new boundaries, drains active Promises, and invalidates retained references', async () => {
|
||||
const { ctx, service, dispose } = await harness()
|
||||
const initiator = agent('draining')
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
const pending = service.withInitiator(initiator, async () => {
|
||||
await release.promise
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
})
|
||||
let disposed = false
|
||||
const disposal = dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(() => service.withInitiator(initiator, () => 1)).toThrow('agent initiator scope is disposed')
|
||||
expect(() => service.withoutInitiator(() => 1)).toThrow('agent initiator scope is disposed')
|
||||
expect(disposed).toBe(false)
|
||||
expect(ctx.get('agents')).toBeUndefined()
|
||||
release.resolve(true)
|
||||
await pending
|
||||
await disposal
|
||||
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
expect(() => service.requireInitiator()).toThrow('agent initiator scope is disposed')
|
||||
})
|
||||
|
||||
it('drains cross-realm Promise boundaries before disposal', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const initiator = agent('cross-realm')
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
const operation = runInNewContext(
|
||||
'(async () => { await release; inspect() })',
|
||||
{
|
||||
release: release.promise,
|
||||
inspect: () => { expect(service.requireInitiator()).toBe(initiator) },
|
||||
},
|
||||
) as () => Promise<void>
|
||||
const pending = service.withInitiator(initiator, operation)
|
||||
expect(pending).not.toBeInstanceOf(Promise)
|
||||
|
||||
let disposed = false
|
||||
const disposal = dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
|
||||
release.resolve(true)
|
||||
await pending
|
||||
await disposal
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
|
||||
it('does not self-deadlock when a boundary returns service disposal', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const initiator = agent('service-disposer')
|
||||
|
||||
const returned = service.withInitiator(initiator, dispose)
|
||||
await promptly(returned)
|
||||
|
||||
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
})
|
||||
|
||||
it('does not self-deadlock when nested boundaries return ancestor disposal', async () => {
|
||||
const { ctx, service } = await harness()
|
||||
const parent = agent('parent-disposer')
|
||||
const child = agent('child-disposer')
|
||||
let disposal: Promise<void> | undefined
|
||||
|
||||
const returned = service.withInitiator(parent, () => service.withInitiator(child, () => {
|
||||
disposal = ctx.fiber.dispose()
|
||||
return disposal
|
||||
}))
|
||||
expect(returned).toBe(disposal)
|
||||
|
||||
await promptly(returned)
|
||||
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
})
|
||||
|
||||
it('excludes an asynchronous teardown initiator while draining unrelated boundaries', async () => {
|
||||
const { ctx, service } = await harness()
|
||||
const initiator = agent('async-disposer')
|
||||
const unrelated = agent('unrelated')
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
const pending = service.withInitiator(unrelated, async () => {
|
||||
await release.promise
|
||||
expect(service.requireInitiator()).toBe(unrelated)
|
||||
})
|
||||
|
||||
const returned = service.withInitiator(initiator, async () => {
|
||||
await Promise.resolve()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
let disposed = false
|
||||
void returned.then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
|
||||
release.resolve(true)
|
||||
await pending
|
||||
await promptly(returned)
|
||||
|
||||
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
})
|
||||
})
|
||||
@@ -2,15 +2,20 @@ import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import type { Events } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, {
|
||||
agentEvents,
|
||||
agentInterruptReasonOf,
|
||||
normalizeAgentCancelCause,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
function stubAgent(rawId: string): Agent {
|
||||
const id = AgentId(rawId)
|
||||
const id = SessionId(rawId)
|
||||
return {
|
||||
id,
|
||||
options: {},
|
||||
session: new Session(SessionId(`${id}-session`)),
|
||||
session: new Session(id),
|
||||
status: 'idle',
|
||||
ctx: new Context(),
|
||||
send() {},
|
||||
@@ -41,6 +46,7 @@ describe('AgentRegistry', () => {
|
||||
const dispose = ctx.agents.register(agent)
|
||||
expect(ctx.agents.get(agent.id)).toBe(agent)
|
||||
expect(ctx.agents.list()).toEqual([agent])
|
||||
expect(ctx.agents.roots()).toEqual([agent])
|
||||
expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/)
|
||||
|
||||
dispose()
|
||||
@@ -48,6 +54,37 @@ describe('AgentRegistry', () => {
|
||||
expect(lifecycle).toEqual(['created:a1', 'disposed:a1'])
|
||||
})
|
||||
|
||||
it('rejects an agent whose registry and session identities differ', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const agent = { ...stubAgent('agent-id'), session: new Session(SessionId('session-id')) }
|
||||
|
||||
expect(() => ctx.agents.enter(agent, undefined))
|
||||
.toThrow('agent id "agent-id" does not match session id "session-id"')
|
||||
expect(ctx.agents.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('tracks runtime creator ownership separately from registry order', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const root = stubAgent('root')
|
||||
const child = stubAgent('child')
|
||||
const detachRoot = ctx.agents.enter(root, undefined)
|
||||
ctx.agents.announce(root)
|
||||
const detachChild = ctx.agents.enter(child, root)
|
||||
ctx.agents.announce(child)
|
||||
|
||||
expect(ctx.agents.list()).toEqual([root, child])
|
||||
expect(ctx.agents.roots()).toEqual([root])
|
||||
expect(ctx.agents.isOwnedBy(child.id, root)).toBe(true)
|
||||
expect(ctx.agents.isOwnedBy(root.id, root)).toBe(false)
|
||||
expect(ctx.agents.isOwnedBy(SessionId('missing'), root)).toBe(false)
|
||||
|
||||
detachChild()
|
||||
expect(ctx.agents.isOwnedBy(child.id, root)).toBe(false)
|
||||
detachRoot()
|
||||
})
|
||||
|
||||
it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
@@ -57,7 +94,7 @@ describe('AgentRegistry', () => {
|
||||
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
|
||||
|
||||
expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto')
|
||||
expect(ctx.agents.get(AgentId('vetoed'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined()
|
||||
expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed'])
|
||||
})
|
||||
|
||||
@@ -93,7 +130,7 @@ describe('AgentRegistry', () => {
|
||||
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
|
||||
|
||||
const first = stubAgent('split')
|
||||
const detachFirst = ctx.agents.enter(first)
|
||||
const detachFirst = ctx.agents.enter(first, undefined)
|
||||
expect(lifecycle).toEqual([])
|
||||
ctx.agents.announce(first)
|
||||
expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/)
|
||||
@@ -101,7 +138,7 @@ describe('AgentRegistry', () => {
|
||||
detachFirst()
|
||||
|
||||
const replacement = stubAgent('split')
|
||||
const detachReplacement = ctx.agents.enter(replacement)
|
||||
const detachReplacement = ctx.agents.enter(replacement, undefined)
|
||||
detachFirst()
|
||||
expect(ctx.agents.get(replacement.id)).toBe(replacement)
|
||||
expect(() => { ctx.agents.announce(first) }).toThrow(/not live/)
|
||||
@@ -121,7 +158,7 @@ describe('AgentRegistry', () => {
|
||||
})
|
||||
ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`))
|
||||
ctx.on('agent/disposed', () => void order.push('disposed'))
|
||||
const detach = ctx.agents.enter(agent)
|
||||
const detach = ctx.agents.enter(agent, undefined)
|
||||
ctx.agents.announce(agent)
|
||||
expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed'])
|
||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||
@@ -149,6 +186,75 @@ describe('agentEvents()', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('explicit cancellation helpers', () => {
|
||||
it('normalizes exact causes into detached frozen values', () => {
|
||||
const user = { kind: 'user' as const }
|
||||
const parent = Object.assign(Object.create(null) as object, { kind: 'parent' })
|
||||
|
||||
const normalizedUser = normalizeAgentCancelCause(user)
|
||||
const normalizedParent = normalizeAgentCancelCause(parent)
|
||||
|
||||
expect(normalizedUser).toEqual({ kind: 'user' })
|
||||
expect(normalizedUser).not.toBe(user)
|
||||
expect(Object.isFrozen(normalizedUser)).toBe(true)
|
||||
expect(normalizedParent).toEqual({ kind: 'parent' })
|
||||
expect(Object.getPrototypeOf(normalizedParent)).toBe(Object.prototype)
|
||||
expect(Object.isFrozen(normalizedParent)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
'user',
|
||||
[],
|
||||
new Error('user'),
|
||||
{ kind: 'user', detail: true },
|
||||
Object.assign({ kind: 'user' }, { [Symbol('extra')]: true }),
|
||||
{ kind: 'timeout' },
|
||||
])('rejects unsupported cancellation cause %#', (cause) => {
|
||||
expect(() => normalizeAgentCancelCause(cause)).toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('reads only supported reasons from an explicit signal', () => {
|
||||
const live = new AbortController()
|
||||
expect(agentInterruptReasonOf(live.signal)).toBeUndefined()
|
||||
|
||||
const user = new AbortController()
|
||||
user.abort({ kind: 'user' })
|
||||
expect(agentInterruptReasonOf(user.signal)).toEqual({ kind: 'user' })
|
||||
|
||||
const disposed = new AbortController()
|
||||
disposed.abort(Object.assign(Object.create(null) as object, { kind: 'disposed' }))
|
||||
const disposedReason = agentInterruptReasonOf(disposed.signal)
|
||||
expect(disposedReason).toEqual({ kind: 'disposed' })
|
||||
expect(Object.isFrozen(disposedReason)).toBe(true)
|
||||
|
||||
const unsupported = new AbortController()
|
||||
unsupported.abort(new Error('private runtime reason'))
|
||||
expect(agentInterruptReasonOf(unsupported.signal)).toBeUndefined()
|
||||
|
||||
const primitive = new AbortController()
|
||||
primitive.abort('private runtime reason')
|
||||
expect(agentInterruptReasonOf(primitive.signal)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not swallow non-validation failures while reading a cause', () => {
|
||||
let reads = 0
|
||||
const reason = Object.defineProperty({}, 'kind', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1
|
||||
if (reads === 1) return 'user'
|
||||
throw new Error('kind getter failed')
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
controller.abort(reason)
|
||||
|
||||
expect(() => agentInterruptReasonOf(controller.signal)).toThrow('kind getter failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentRegistry factory seam', () => {
|
||||
function stubFactory() {
|
||||
const calls: {
|
||||
@@ -158,11 +264,11 @@ describe('AgentRegistry factory seam', () => {
|
||||
const factory: AgentFactory = {
|
||||
async createAgent(ownerCtx, options) {
|
||||
calls.create.push({ ownerCtx, options })
|
||||
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
|
||||
return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() }
|
||||
},
|
||||
async resume(ownerCtx, options) {
|
||||
calls.resume.push({ ownerCtx, options })
|
||||
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
|
||||
return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() }
|
||||
},
|
||||
}
|
||||
return { factory, calls }
|
||||
@@ -171,15 +277,15 @@ describe('AgentRegistry factory seam', () => {
|
||||
it('requires a factory and delegates through the calling context', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
|
||||
await expect(ctx.agents.create({ sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
|
||||
const { factory, calls } = stubFactory()
|
||||
ctx.agents.setFactory(factory)
|
||||
|
||||
let callerFiber: Context['fiber'] | undefined
|
||||
await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
callerFiber = inner.fiber
|
||||
await inner.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') })
|
||||
await inner.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') })
|
||||
await inner.agents.create({ sessionId: SessionId('create-s') })
|
||||
await inner.agents.resume({ resumeSessionId: SessionId('resume-s') })
|
||||
}, { inject: ['agents'] }))
|
||||
expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber)
|
||||
expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber)
|
||||
@@ -192,9 +298,9 @@ describe('AgentRegistry factory seam', () => {
|
||||
inner.agents.setFactory(stubFactory().factory)
|
||||
expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
|
||||
}, { inject: ['agents'] }))
|
||||
await expect(ctx.agents.create({ agentId: AgentId('before'), sessionId: SessionId('before-s') })).resolves.toBeDefined()
|
||||
await expect(ctx.agents.create({ sessionId: SessionId('before-s') })).resolves.toBeDefined()
|
||||
await owner.dispose()
|
||||
await expect(ctx.agents.create({ agentId: AgentId('after'), sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/)
|
||||
await expect(ctx.agents.create({ sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/)
|
||||
})
|
||||
|
||||
it('canonicalizes an already traced Service before tracing it for the caller', async () => {
|
||||
@@ -214,18 +320,18 @@ describe('AgentRegistry factory seam', () => {
|
||||
}
|
||||
async createAgent(_ownerCtx: Context, options: CreateAgentOptions) {
|
||||
this.calls().push('create')
|
||||
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
|
||||
return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() }
|
||||
}
|
||||
async resume(_ownerCtx: Context, options: ResumeAgentOptions) {
|
||||
this.calls().push('resume')
|
||||
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
|
||||
return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() }
|
||||
}
|
||||
}
|
||||
await ctx.plugin(TracedFactory)
|
||||
const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory
|
||||
ctx.agents.setFactory(traced)
|
||||
await ctx.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') })
|
||||
await ctx.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') })
|
||||
await ctx.agents.create({ sessionId: SessionId('create-s') })
|
||||
await ctx.agents.resume({ resumeSessionId: SessionId('resume-s') })
|
||||
const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original]
|
||||
expect(states.get(raw!)).toEqual(['create', 'resume'])
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Negative-path tests for the cordis catalog generator (`scripts/gen-cordis-catalog.ts`).
|
||||
* Contract and negative-path tests for the cordis catalog generator
|
||||
* (`scripts/gen-cordis-catalog.ts`).
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectEvents, collectServices } from '../../../../scripts/gen-cordis-catalog.ts'
|
||||
import { collectEvents, collectServices, renderEvents, renderServices } from '../../../../scripts/gen-cordis-catalog.ts'
|
||||
|
||||
/** Write a fixture package exposing one `interface Events` block and return the
|
||||
* scan root to hand `collectEvents`. */
|
||||
@@ -58,6 +59,8 @@ describe('gen-cordis-catalog collectEvents', () => {
|
||||
))
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' })
|
||||
expect(events[0]?.jsDoc).toBe('/**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */')
|
||||
expect(renderEvents(events)).toContain("```ts cordis-catalog\n/**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n'fix/happened'(id: string): void\n```")
|
||||
})
|
||||
|
||||
it('classifies a trailing-next signature as a waterfall', () => {
|
||||
@@ -74,6 +77,33 @@ describe('gen-cordis-catalog collectEvents', () => {
|
||||
expect(events[0]?.mode).toBe('parallel')
|
||||
})
|
||||
|
||||
it('accepts linked, foundation, generic-parameter, and explicitly exempt signature types', () => {
|
||||
const events = collectEvents(make(
|
||||
' /**\n * Carry linked and foundation types.\n * @param value - the linked value.\n * @param preset - deployment metadata outside the core catalog.\n * @param signal - cancellation.\n * @mode parallel\n */\n \'fix/typed\'<T extends SessionEvent>(value: Readonly<T>, preset: PresetSpec, signal: AbortSignal): Promise<T>',
|
||||
))
|
||||
expect(events).toHaveLength(1)
|
||||
expect(renderEvents(events)).toContain('Types: [SessionEvent](../core-data-structures/core.md)')
|
||||
expect(renderEvents(events)).not.toContain('[PresetSpec]')
|
||||
})
|
||||
|
||||
it('aggregates every unclassified signature type with its source and remediation', () => {
|
||||
const expected = new RegExp([
|
||||
'2 signature type-link coverage violation\\(s\\)',
|
||||
'fix/one',
|
||||
'packages/group/fix/src/index.ts',
|
||||
'MissingOne',
|
||||
'fix/two',
|
||||
'packages/group/fix/src/index.ts',
|
||||
'missingTwo',
|
||||
'Add it to LINK_MAP',
|
||||
'FOUNDATION_TYPE_NAMES',
|
||||
'TYPE_LINK_EXEMPTIONS',
|
||||
].join('[\\s\\S]*'))
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * First.\n * @param value - first value.\n * @mode emit\n */\n \'fix/one\'(value: MissingOne): void\n /**\n * Second.\n * @param value - second value.\n * @mode emit\n */\n \'fix/two\'(value: missingTwo): void',
|
||||
))).toThrow(expected)
|
||||
})
|
||||
|
||||
it('hard-errors when an event is missing its @mode tag', () => {
|
||||
expect(() => collectEvents(make(
|
||||
' /** No mode here. */\n \'fix/untagged\'(): void',
|
||||
@@ -158,26 +188,17 @@ export class FixService {
|
||||
expect(services).toHaveLength(1)
|
||||
expect(services[0]).toMatchObject({ key: 'fix', type: 'FixService', abstract: false, doc: 'Fixture service.' })
|
||||
expect(services[0]?.methods).toHaveLength(3)
|
||||
expect(services[0]?.methods[0]).toEqual({
|
||||
signature: 'run(id: string): string',
|
||||
jsDoc: '/**\n * Do the thing.\n * @param id - which thing to do.\n * @returns the outcome of doing it.\n */',
|
||||
})
|
||||
expect(renderServices(services)).toContain('```ts cordis-catalog\n/**\n * Do the thing.\n * @param id - which thing to do.\n * @returns the outcome of doing it.\n */\nrun(id: string): string\n\n/** Fire and forget (void needs no @returns). */\npoke(): void')
|
||||
})
|
||||
|
||||
it('extracts an interface service as an abstract seam', () => {
|
||||
const services = collectServices(makeService(`/** Fixture service interface. */
|
||||
export interface FixService {
|
||||
/**
|
||||
* Do the thing.
|
||||
* @param id - which thing to do.
|
||||
* @returns the outcome of doing it.
|
||||
*/
|
||||
run(id: string): string
|
||||
}`))
|
||||
expect(services).toHaveLength(1)
|
||||
expect(services[0]).toMatchObject({
|
||||
key: 'fix',
|
||||
type: 'FixService',
|
||||
abstract: true,
|
||||
doc: 'Fixture service interface.',
|
||||
})
|
||||
expect(services[0]?.methods).toEqual(['run(id: string): string'])
|
||||
it('hard-errors on an unclassified service-method signature type', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Use an unknown value.\n * @param value - the value.\n */\n run(value: MissingServiceType): void {}\n}',
|
||||
))).toThrow(/service method ctx\.fix\.run .* references unclassified type 'MissingServiceType'/)
|
||||
})
|
||||
|
||||
it('hard-errors on a public method with no JSDoc at all', () => {
|
||||
|
||||
Reference in New Issue
Block a user