fix(scope): make the dispatch carrier method-transparent for native-private subjects

ds-review-bot delta-round finding, verified: cordis hands the carrier to
listeners as `this`, and the event declarations type it Scoped<Agent> — so
driving the subject through it (this.send(...) in an agent/* listener) is a
SUPPORTED shape. The withProps-based carrier delegated gets with the PROXY
as receiver, so ReactLoopAgent's send/steer/cancel — which read the
native-private #carrier through a getter — threw TypeError when called that
way (private members do not exist on proxy receivers).

scopeTarget now builds its own proxy: overlay props (the composed filter and
the carrier mark) answer from a null-shadowed literal via hasOwn (`in` would
let Object.prototype's toString/constructor shadow the subject's), every
other get delegates with the BASE as receiver (getters see the real object)
and returns functions bound to the base (method calls execute on the real
receiver), sets land on the base. A proxy-invariant guard reports frozen own
function props unchanged (binding them would violate the get invariant).
This kills the class at the seam — any subject with native privates works,
today's agents and whatever carries them next — instead of patching the one
#carrier field.

Pinned both ways: a scope.spec matrix (native-#private method/getter through
the carrier mutates the real object; set delegation; frozen-own-prop
invariant; overlay non-shadowing) and the bot's exact end-to-end scenario
(an agent/session-start listener calling this.send drives a real turn) —
both fail with TypeError against the withProps carrier.
This commit is contained in:
Tianyi Cui
2026-07-09 14:21:58 +08:00
parent 7c5133488a
commit 06cebf1bf4
3 changed files with 111 additions and 15 deletions

View File

@@ -170,6 +170,29 @@ describe('agent scope lifecycle', () => {
expect(heard).toEqual(['a1:2'])
})
it('a listener may drive the agent through its declared `this` (the carrier is method-transparent)', async () => {
// ds-review-bot regression: agent/* listeners are typed
// `this: Scoped<Agent>`, and ReactLoopAgent's send/steer/cancel read the
// native-private #carrier — a proxy-receiver carrier made
// `this.send(...)` throw TypeError. The carrier binds methods to the real
// agent, so driving through the event `this` is a working supported shape.
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let followUpSent = false
ctx.on('agent/session-start', function (this: Agent) {
// Deliberately through `this`, not the args subject.
this.send(text('driven through this'))
followUpSent = true
})
const second = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
expect(followUpSent).toBe(true)
await second.whenIdle()
// The send actually reached the loop: the prompt ran a turn.
expect(second.session.events.some(e => e.type === 'turn/start')).toBe(true)
await agent.whenIdle()
})
it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => {
const ctx = await harness()
let handle!: ReturnType<typeof ctx.agents.create>