refactor(core): simplify scoped agent lifecycles

This commit is contained in:
Tianyi Cui
2026-07-12 22:36:04 +08:00
parent e8fed4fb66
commit 28e04ff4fb
24 changed files with 1080 additions and 4097 deletions

View File

@@ -89,15 +89,15 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1))
})
it('clones content off the log: the projection never aliases the logged event', () => {
it('reuses the logged event\'s already frozen content', () => {
const session = new Session(SessionId('per-event-clone'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', { content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const message = session.deriveEventMessage(event)!
expect(message.content).not.toBe(event.data.content)
// deriveEventMessage returns an unfrozen clone (the cache freezes ITS
// copies); mutating it must not reach the log.
;(message.content[0] as { text: string }).text = 'mutated'
expect(message.content).toBe(event.data.content)
expect(Object.isFrozen(message.content)).toBe(true)
expect(Object.isFrozen(message.content[0])).toBe(true)
expect(() => { (message.content[0] as { text: string }).text = 'mutated' }).toThrow()
expect(session.deriveMessages().at(-1)!.content).toEqual([{ type: 'text', text: 'orig' }])
})

View File

@@ -154,21 +154,6 @@ describe('sessions.flush()', () => {
expect(flushed).toEqual([])
})
it('rejects internal dispatch substitution before flush callbacks run', async () => {
const ctx = await mount()
const session = ctx.sessions.create()
const replacement = ctx.sessions.create()
const flushed: Session[] = []
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name === 'session/flush') args[0] = replacement
})
ctx.on('session/flush', (candidate) => { flushed.push(candidate) })
await expect(ctx.sessions.flush(session))
.rejects.toThrow('session/flush internal dispatch replaced the accepted callback tuple')
expect(flushed).toEqual([])
})
it('clears a detached carrier and rejects stale flushes', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')

View File

@@ -129,16 +129,6 @@ describe('Session', () => {
expect(session.events).toHaveLength(0)
})
it('rejects a non-string event type without retaining or freezing caller data', () => {
const session = new Session(SessionId('invalid-event-type'))
const type = { tag: 'caller-owned' }
const appendRaw = session.append.bind(session) as unknown as (type: unknown, data: unknown) => SessionEvent
expect(() => appendRaw(type, {})).toThrow(/event type must be a string/)
expect(Object.isFrozen(type)).toBe(false)
expect(session.events).toEqual([])
})
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
const session = new Session(SessionId('s5b'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -282,7 +272,7 @@ describe('Session', () => {
const seed: SessionEvent[] = [new SeedEvent()]
expect(() => new Session(SessionId('seed-exotic-shell'), seed))
.toThrow(/not a plain JSON record/)
.toThrow(/not losslessly JSON-serializable/)
})
it('accepts a null-prototype seed event shell as a plain JSON record', () => {
@@ -396,26 +386,6 @@ describe('Session', () => {
expect(session.events).toEqual([event])
})
it('reads surface metadata accessors once so a validated marker is logged', () => {
const session = new Session(SessionId('surface-intent-snapshot'))
let reads = 0
const intent = {
get surfaceOp(): 'append' | undefined {
reads += 1
return reads === 1 ? 'append' : undefined
},
}
const event = session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
intent as { surfaceOp: 'append' },
)
expect(reads).toBe(1)
expect(event.surfaceOp).toBe('append')
})
it('rejects non-JSON surface metadata before appending the event', () => {
const session = new Session(SessionId('append-bad-metadata'))
@@ -578,44 +548,10 @@ describe('Session', () => {
expect(session.header).not.toBe(input)
expect(Object.isFrozen(session.header)).toBe(true)
expect(Reflect.set(session.header, 'cwd', '/published-mutated')).toBe(false)
expect(Reflect.set(session, 'id', SessionId('redirected'))).toBe(false)
expect(Reflect.set(session, 'header', input)).toBe(false)
expect(Object.getOwnPropertyDescriptor(session, 'id')).toMatchObject({
configurable: false,
writable: false,
})
expect(Object.getOwnPropertyDescriptor(session, 'header')).toMatchObject({
configurable: false,
writable: false,
})
expect(session.id).toBe('header-owned')
expect(session.header.cwd).toBe('/accepted')
})
it('reads each supplied header field once before validation and publication', () => {
const reads = { version: 0, id: 0, createdAt: 0, cwd: 0, parentSession: 0, seedLength: 0 }
const header = {
get version() { reads.version += 1; return reads.version === 1 ? SESSION_FORMAT_VERSION : 99 },
get id() { reads.id += 1; return reads.id === 1 ? SessionId('header-once') : SessionId('drifted') },
get createdAt() { reads.createdAt += 1; return reads.createdAt === 1 ? 123 : Number.NaN },
get cwd() { reads.cwd += 1; return reads.cwd === 1 ? '/accepted' : 'relative' },
get parentSession() { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n },
get seedLength() { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n },
} as unknown as SessionHeader
const session = new Session(SessionId('header-once'), undefined, header)
expect(reads).toEqual({ version: 1, id: 1, createdAt: 1, cwd: 1, parentSession: 1, seedLength: 1 })
expect(session.header).toEqual({
version: SESSION_FORMAT_VERSION,
id: 'header-once',
createdAt: 123,
cwd: '/accepted',
parentSession: 'parent',
seedLength: 0,
})
})
it('rejects an exotic, non-JSON, or mismatched supplied header', () => {
class ExoticHeader implements SessionHeader {
readonly version = SESSION_FORMAT_VERSION
@@ -624,7 +560,7 @@ describe('Session', () => {
}
expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader()))
.toThrow(/not a plain JSON record/)
.toThrow(/not losslessly JSON-serializable/)
expect(() => new Session(SessionId('header-invalid'), undefined, {
version: SESSION_FORMAT_VERSION,
id: SessionId('header-invalid'),
@@ -740,79 +676,6 @@ describe('SessionStore', () => {
expect(ctx.sessions.get(SessionId('racy'))).toBe(live)
})
it('claims an id across Context.filter evaluation before committing the exact session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const id = SessionId('reentrant-enter')
const nested = new Session(id)
const outer = new Session(id)
let nestedError = ''
let attempted = false
Object.defineProperty(outer, Context.filter, {
configurable: true,
get() {
if (!attempted) {
attempted = true
try {
ctx.sessions.enter(nested)
} catch (error: unknown) {
nestedError = String(error)
}
}
return undefined
},
})
const detach = ctx.sessions.enter(outer)
expect(nestedError).toMatch(/already exists/)
expect(ctx.sessions.get(id)).toBe(outer)
detach()
expect(ctx.sessions.get(id)).toBeUndefined()
})
it('revalidates reservation ownership after carrier construction runs caller code', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const id = SessionId('released-during-enter')
const reservation = ctx.sessions.reserve(id)
const session = reservation.prepare()
Object.defineProperty(session, Context.filter, {
configurable: true,
get() {
reservation.release()
return undefined
},
})
expect(() => ctx.sessions.enter(session, reservation)).toThrow(/does not own this prepared session/)
expect(ctx.sessions.get(id)).toBeUndefined()
})
it('rejects when carrier construction attaches the same session to another store', async () => {
const firstCtx = new Context()
const secondCtx = new Context()
await firstCtx.plugin(SessionStore)
await secondCtx.plugin(SessionStore)
const session = new Session(SessionId('cross-store-carrier'))
let attempted = false
let detachSecond = (): void => {}
Object.defineProperty(session, Context.filter, {
configurable: true,
get() {
if (!attempted) {
attempted = true
detachSecond = secondCtx.sessions.enter(session)
}
return undefined
},
})
expect(() => firstCtx.sessions.enter(session)).toThrow(/already attached to a store/)
expect(firstCtx.sessions.get(session.id)).toBeUndefined()
expect(secondCtx.sessions.get(session.id)).toBe(session)
detachSecond()
})
it('prepare() + enter() + announce() register a session and emit session/created', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -834,7 +697,7 @@ describe('SessionStore', () => {
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
})
it('captures the accepted id once and prevents simultaneous attachment to two stores', async () => {
it('prevents simultaneous attachment of one session object to two stores', async () => {
const firstCtx = new Context()
const secondCtx = new Context()
await firstCtx.plugin(SessionStore)
@@ -842,7 +705,6 @@ describe('SessionStore', () => {
const session = new Session(SessionId('owned-key'))
const detachFirst = firstCtx.sessions.enter(session)
expect(Reflect.set(session, 'id', SessionId('redirected'))).toBe(false)
expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/)
expect(firstCtx.sessions.get(SessionId('owned-key'))).toBe(session)
@@ -852,69 +714,6 @@ describe('SessionStore', () => {
expect(secondCtx.sessions.get(SessionId('owned-key'))).toBe(session)
detachSecond()
expect(() => firstCtx.sessions.enter({ id: 42 } as unknown as Session)).toThrow(/id must be a string/)
})
it('uses an opaque one-session reservation to gate unpublished factory insertion', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const held = ctx.sessions.reserve(SessionId('held-session'))
expect(() => ctx.sessions.reserve(SessionId('held-session'))).toThrow(/already exists or is reserved/)
expect(() => ctx.sessions.prepare(SessionId('held-session'))).toThrow(/reserved for unpublished creation/)
expect(() => ctx.sessions.create(SessionId('held-session'))).toThrow(/reserved for unpublished creation/)
const session = held.prepare({ meta: { cwd: '/held' } })
expect(() => held.prepare()).toThrow(/already prepared/)
expect(() => ctx.sessions.enter(session)).toThrow(/reserved for unpublished creation/)
const other = ctx.sessions.reserve(SessionId('other-session'))
expect(() => ctx.sessions.enter(session, other)).toThrow(/does not own this prepared session/)
expect(() => ctx.sessions.enter(new Session(SessionId('held-session')), held))
.toThrow(/does not own this prepared session/)
const detach = ctx.sessions.enter(session, held)
ctx.sessions.announce(session)
held.release()
held.release()
expect(ctx.sessions.get(SessionId('held-session'))).toBe(session)
expect(() => ctx.sessions.reserve(SessionId('held-session'))).toThrow(/already exists or is reserved/)
detach()
other.release()
const expired = ctx.sessions.reserve(SessionId('expired-session'))
expired.release()
expect(() => expired.prepare()).toThrow(/no longer active/)
expect(() => ctx.sessions.enter(new Session(SessionId('expired-session')), expired))
.toThrow(/does not own this prepared session/)
expect(() => ctx.sessions.reserve(42 as unknown as SessionId)).toThrow(/id must be a string/)
expect(() => ctx.sessions.prepare(42 as unknown as SessionId)).toThrow(/id must be a string/)
// Auto-generated ids skip unpublished reservations just as they skip live
// store entries; no hidden collision can be published later.
const firstAuto = ctx.sessions.reserve(SessionId('session-1'))
expect(ctx.sessions.prepare().id).toBe('session-2')
firstAuto.release()
})
it('owns reservations by the calling fiber and rolls back failed ownership registration', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let held!: import('@deepseek-ai/dsh-session').SessionRegistrationReservation
let scopedSessions!: SessionStore
const owner = await ctx.plugin(Object.assign((inner: Context) => {
scopedSessions = inner.sessions
held = inner.sessions.reserve(SessionId('fiber-held'))
}, { inject: ['sessions'] }))
expect(() => ctx.sessions.reserve(SessionId('fiber-held'))).toThrow(/already exists or is reserved/)
await owner.dispose()
const reused = ctx.sessions.reserve(SessionId('fiber-held'))
reused.release()
held.release() // idempotent after the automatic owner-disposal release
expect(() => scopedSessions.reserve(SessionId('inactive-owner'))).toThrow(/inactive context/)
const recovered = ctx.sessions.reserve(SessionId('inactive-owner'))
recovered.release()
})
it('rejects direct and reentrant repeat announcements to preserve one lifecycle pair', async () => {
@@ -1009,54 +808,14 @@ describe('SessionStore', () => {
})
})
it('reads session options and each metadata field once in prepare()', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const reads = { seed: 0, meta: 0, cwd: 0, parentSession: 0, createdAt: 0, seedLength: 0 }
const meta = {
get cwd() { reads.cwd += 1; return reads.cwd === 1 ? '/accepted' : 'relative' },
get parentSession() { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n },
get createdAt() { reads.createdAt += 1; return reads.createdAt === 1 ? 123 : Number.NaN },
get seedLength() { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n },
}
const options = {
get seed() { reads.seed += 1; return reads.seed === 1 ? undefined : [] },
get meta() { reads.meta += 1; return reads.meta === 1 ? meta : undefined },
} as unknown as CreateSessionOptions
const session = ctx.sessions.prepare(SessionId('metadata-once'), options)
expect(reads).toEqual({ seed: 1, meta: 1, cwd: 1, parentSession: 1, createdAt: 1, seedLength: 1 })
expect(session.header).toEqual({
version: SESSION_FORMAT_VERSION,
id: 'metadata-once',
createdAt: 123,
cwd: '/accepted',
parentSession: 'parent',
seedLength: 0,
})
})
it('rejects exotic metadata before cloning can erase its prototype', async () => {
class ExoticMeta {
readonly cwd = '/accepted'
}
const ctx = new Context()
await ctx.plugin(SessionStore)
expect(() => ctx.sessions.prepare(SessionId('exotic-meta'), { meta: new ExoticMeta() }))
.toThrow(/session metadata is not a plain JSON record/)
})
it('rejects non-JSON and invalid scalar session metadata', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const cases: Array<{ meta: unknown; error: RegExp }> = [
{ meta: 1, error: /metadata is not a plain JSON record/ },
{ meta: { parentSession: 1n }, error: /metadata is not losslessly JSON-serializable/ },
{ meta: { cwd: 1 }, error: /session cwd must be a string/ },
{ meta: { parentSession: 1 }, error: /parentSession must be a string/ },
{ meta: { createdAt: '123' }, error: /createdAt must be a finite number/ },
{ meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
{ meta: { cwd: 1 }, error: /header cwd must be a string/ },
{ meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
{ meta: { createdAt: '123' }, error: /header createdAt must be a finite number/ },
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
@@ -1224,105 +983,6 @@ describe('SessionStore', () => {
expect(observed).toEqual([])
})
it('rejects prepend or append instrumentation that replaces the accepted observer tuple', async () => {
for (const prepend of [true, false]) {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId(`dispatch-tuple-${prepend}`))
const replacementSession = new Session(SessionId('replacement'))
const replacementEvent = {
type: 'turn/end',
seq: 99,
time: 1,
data: { turn: 99, reason: { kind: 'completed' } },
} as SessionEvent
const observed: Array<{ session: Session; event: SessionEvent }> = []
let replace = true
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event' || !replace) return
args[0] = replacementSession
args[1] = replacementEvent
}, { prepend })
ctx.on('session/event', (observedSession, event) => {
observed.push({ session: observedSession, event })
})
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('session/event internal dispatch replaced the accepted callback tuple')
expect(session.events).toEqual([])
expect(observed).toEqual([])
replace = false
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(session.events).toEqual([appended])
expect(observed).toEqual([{ session, event: appended }])
}
})
it('rejects if a bare session becomes attached while caller data is materialized', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = new Session(SessionId('attach-during-append'))
const observed: SessionEvent[] = []
let sessionEventDispatches = 0
let detach!: () => void
ctx.on('internal/dispatch', (_mode, name) => {
if (name === 'session/event') sessionEventDispatches += 1
})
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
const data = {
get todos(): TodoItem[] {
detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
return []
},
}
expect(() => session.append('todo/write', data))
.toThrow('session attachment changed while append input was being accepted')
expect(ctx.sessions.get(session.id)).toBe(session)
expect(session.events).toEqual([])
expect(sessionEventDispatches).toBe(0)
expect(observed).toEqual([])
const appended = session.append('todo/write', { todos: [] })
expect(session.events).toEqual([appended])
expect(sessionEventDispatches).toBe(1)
expect(observed).toEqual([appended])
detach()
})
it('rejects a transient attach and detach while caller data is materialized', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = new Session(SessionId('attach-detach-during-append'))
const lifecycle: string[] = []
const observed: SessionEvent[] = []
ctx.on('session/created', () => { lifecycle.push('created') })
ctx.on('session/disposed', () => { lifecycle.push('disposed') })
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
const data = {
get todos(): TodoItem[] {
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
detach()
return []
},
}
expect(() => session.append('todo/write', data))
.toThrow('session attachment changed while append input was being accepted')
expect(ctx.sessions.get(session.id)).toBeUndefined()
expect(session.events).toEqual([])
expect(lifecycle).toEqual(['created', 'disposed'])
expect(observed).toEqual([])
})
it('contains a reentrant observer append without reordering later observers', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -1342,34 +1002,10 @@ describe('SessionStore', () => {
expect(session.events).toEqual([appended])
expect(heard).toEqual([appended])
expect(warnings).toEqual([
'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being accepted or published',
'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being published',
])
})
it('keeps observer failures contained when warning output itself throws', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.logger.warn = (() => { throw new Error('logger unavailable') }) as typeof ctx.logger.warn
const session = ctx.sessions.create(SessionId('throwing-logger'))
const heard: SessionEvent[] = []
ctx.on('session/event', () => { throw new Error('sync observer') })
ctx.on('session/event', () => Promise.reject(new Error('async observer')) as never)
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
let appended!: SessionEvent
expect(() => {
appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
}).not.toThrow()
await Promise.resolve()
await Promise.resolve()
expect(session.events).toEqual([appended])
expect(heard).toEqual([appended])
})
it('defers detach through dispatch resolution, commit, and observer publication', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -1425,12 +1061,9 @@ describe('SessionStore', () => {
await ctx.plugin(SessionStore)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const hostile = { [Symbol.toPrimitive]() { throw new Error('cannot stringify') } }
const printable = { toString: () => 'printable failure' }
const heard: string[] = []
ctx.on('session/disposed', () => { throw hostile })
ctx.on('session/disposed', () => { throw new Error('sync disposed') })
ctx.on('session/disposed', () => Promise.reject(new Error('async disposed')) as never)
ctx.on('session/disposed', () => { throw printable })
ctx.on('session/disposed', (session) => { heard.push(session.id) })
const unannounced = ctx.sessions.prepare(SessionId('never-announced'))
@@ -1447,8 +1080,7 @@ describe('SessionStore', () => {
expect(heard).toEqual(['contained-disposal'])
expect(warnings).toEqual([
'session "contained-disposal": session/disposed listener threw: <unrenderable thrown value>',
'session "contained-disposal": session/disposed listener threw: printable failure',
'session "contained-disposal": session/disposed listener threw: Error: sync disposed',
'session "contained-disposal": session/disposed listener rejected: Error: async disposed',
])
})