fix(events): address review — core.md turn-only taxonomy, RFC mechanism names, post-execute content snapshot

- core-data-structures/core.md: the `agent/*` taxonomy said "turn/step
  boundaries", but the step-boundary mirror emits were dropped — `agent/*`
  mirrors only turn boundaries; step boundaries are durable `step/start`/
  `step/end` session events. Narrow the catalog so plugin authors aren't pointed
  at nonexistent `agent/*` step events.
- interception-seams RFC: replace stack-position phrasing ("a later stack PR",
  "the stack's first change", "the PR that makes...") with durable mechanism/RFC
  names (the hook bridge packages, the event-domain-semantics RFC).
- tools/post-execute snapshot: `dispatched.content` was the same array reference
  as `result.content`, so a listener's in-place `push`/`splice` leaked into the
  returned content while a reassignment was masked — the "protect from tampering"
  comment over-claimed. Copy content into a fresh array so the snapshot guards
  the array structure; comment now states it is not deep immutability. Regression
  extended to push a block in-place and assert it does not leak (proven red
  without the copy).
This commit is contained in:
Tianyi Cui
2026-07-01 15:39:08 +08:00
parent 5bdb40ff34
commit 3712f67bc6
4 changed files with 14 additions and 8 deletions

View File

@@ -471,10 +471,13 @@ export class ToolRegistry extends Service {
// authoritative-call-id requirement and the "preserve the dispatched
// isError/error" contract. The decision is the ONLY sanctioned channel for a
// listener to change the outcome (block, or accept-with-replacement); the
// call id is always the authoritative `exec.callId`.
// call id is always the authoritative `exec.callId`. `content` is copied into
// a fresh array so a listener's in-place `push`/`splice` on `result.content`
// cannot leak into the returned content either (the elements are the same
// references — the snapshot guards the array structure, not deep immutability).
const dispatched = {
callId: exec.callId,
content: result.content,
content: [...result.content],
isError: result.isError,
...result.error ? { error: result.error } : {},
}

View File

@@ -211,10 +211,11 @@ describe('ToolRegistry', () => {
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async (_exec, result, next) => {
const mutable = result as { callId: string; isError: boolean; error?: unknown }
const mutable = result as { callId: string; isError: boolean; error?: unknown; content: unknown[] }
mutable.callId = 'hijacked'
mutable.isError = true
mutable.error = { name: 'Evil', code: 'EVIL' }
mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation
return next() // delegate to the default accept — no decision-level override
})
@@ -222,7 +223,9 @@ describe('ToolRegistry', () => {
expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked'
expect(result.isError).toBe(false) // the real (successful) dispatch outcome
expect(result.error).toBeUndefined() // no listener-injected error
expect(result.content).toHaveLength(1) // the in-place push did not leak in
expect(result.content[0]).toMatchObject({ text: 'hi' })
expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false)
})
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {