Merge origin/master into worktree/explicit-turn-signal

This commit is contained in:
Yichen Jiang
2026-07-20 21:38:49 +08:00
1322 changed files with 47895 additions and 20257 deletions

View File

@@ -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'])
})