fix(scope): harden final ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 05:13:17 +08:00
parent 36b8370027
commit a9cb70d896
52 changed files with 2839 additions and 514 deletions

View File

@@ -7,7 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
@@ -49,6 +49,34 @@ function send(agent: ReactLoopAgent, text: string) {
}
describe('ReactLoopAgent', () => {
it('owns immutable runtime bindings for id, options, session, and scoped context', 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.id).toBe('owned-bindings')
expect(agent.session).toBe(acceptedSession)
expect(agent.ctx).toBe(acceptedContext)
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()
})
it('send() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
@@ -197,6 +225,23 @@ 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)
@@ -416,7 +461,7 @@ describe('ReactLoopAgent', () => {
expect(adapter.requests).toHaveLength(1)
expect(agent.status).toBe('idle')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on running'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
warn.mockRestore()
})
@@ -434,7 +479,7 @@ describe('ReactLoopAgent', () => {
expect(adapter.requests).toHaveLength(1)
expect(agent.status).toBe('idle')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on idle'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
warn.mockRestore()
})
})