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

@@ -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))