From c678db9ca007bcc9c5a46b2c7113805e47229e75 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 4 Oct 2026 10:09:25 +0000 Subject: [PATCH 1/4] fix: return event rewrites and context without rendering --- .changeset/direct-event-results.md | 5 +++ packages/agent-bundle/src/events/handler.ts | 39 +++++++++++++---- .../agent-bundle/src/events/projection.ts | 31 ++++++++------ .../src/routes/event-definitions.ts | 4 +- .../event-handler-artifact-graph.test.ts | 18 +++++++- .../agent-bundle/tests/event-handler.test.ts | 42 ++++++++++++++++++- .../tests/function-authoring.test.ts | 12 +++++- website/docs/en/guide/authoring/hooks.mdx | 17 ++++++++ website/docs/zh/guide/authoring/hooks.mdx | 16 +++++++ 9 files changed, 158 insertions(+), 26 deletions(-) create mode 100644 .changeset/direct-event-results.md diff --git a/.changeset/direct-event-results.md b/.changeset/direct-event-results.md new file mode 100644 index 000000000..1781c507d --- /dev/null +++ b/.changeset/direct-event-results.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Return `updatedInput` and `additionalContext` directly from `events` handlers without starting a render worker. diff --git a/packages/agent-bundle/src/events/handler.ts b/packages/agent-bundle/src/events/handler.ts index 1ccac9d79..1c78a4a9d 100644 --- a/packages/agent-bundle/src/events/handler.ts +++ b/packages/agent-bundle/src/events/handler.ts @@ -1,14 +1,18 @@ import { settleBeforeAbort } from '../core/abort.ts'; -import { isRecord, snapshotStrictJsonValue, type JsonValue } from '../core/strict-json.ts'; +import { isJsonRecord, isRecord, snapshotStrictJsonValue, type JsonObject, type JsonValue } from '../core/strict-json.ts'; import { eventContracts, type CanonicalAgentEvent } from '../routes/events.ts'; import type { AgentEventCanonicalIdentity } from '../routes/public.ts'; import type { AgentTerminal } from '../terminal-capability.ts'; import type { EventTracer } from './trace.ts'; -export type EventHandlerResult = +export type EventHandlerResult< + Data extends JsonValue = JsonValue, + Event extends CanonicalAgentEvent = CanonicalAgentEvent, +> = | { readonly outcome: 'render'; readonly module: string; readonly data: Data } - | { readonly outcome: 'continue' } - | { readonly outcome: 'deny'; readonly reason: string }; + | ({ readonly outcome: 'continue'; readonly additionalContext?: string } & + (Event extends 'tool/before' ? { readonly updatedInput?: JsonObject } : { readonly updatedInput?: never })) + | { readonly outcome: 'deny'; readonly reason: string; readonly additionalContext?: string }; export interface EventHandlerContext { readonly render: (module: string, data: JsonValue) => Extract; @@ -43,6 +47,14 @@ const unexpectedFields = (record: Readonly>, allowed: Re } }; +const handlerContext = (value: Readonly>): { readonly additionalContext?: string } => { + if (value.additionalContext === undefined) return {}; + if (typeof value.additionalContext !== 'string') { + throw new TypeError('Event handler additionalContext must be a string.'); + } + return { additionalContext: value.additionalContext }; +}; + /** * Validates a runtime handler return into {@link EventHandlerResult}. * Unknown outcomes, extra fields, an empty denial reason, and deny on a @@ -66,18 +78,27 @@ export const validateEventHandlerResult = ( unexpectedFields(value, new Set(['outcome', 'module', 'data'])); if (view === undefined || value.module !== view) throw new TypeError('ctx.render() must name the event handler’s sibling .view.js module.'); return Object.freeze({ data: snapshotStrictJsonValue(value.data), module: view, outcome: 'render' }); - case 'continue': - unexpectedFields(value, new Set(['outcome'])); - return Object.freeze({ outcome: 'continue' }); + case 'continue': { + unexpectedFields(value, new Set(['outcome', 'updatedInput', 'additionalContext'])); + const updatedInput = value.updatedInput === undefined ? undefined : snapshotStrictJsonValue(value.updatedInput); + if (updatedInput !== undefined && (event !== 'tool/before' || !isJsonRecord(updatedInput))) { + throw new TypeError('Event handler updatedInput must be an object on tool/before.'); + } + return Object.freeze({ + outcome: 'continue', + ...(updatedInput === undefined ? {} : { updatedInput }), + ...handlerContext(value), + }); + } case 'deny': { - unexpectedFields(value, new Set(['outcome', 'reason'])); + unexpectedFields(value, new Set(['outcome', 'reason', 'additionalContext'])); if (!eventContracts[event].deny) { throw new TypeError(`${event} cannot deny from handler.`); } if (typeof value.reason !== 'string' || value.reason.trim() === '') { throw new TypeError(`${event} requires a nonempty reason when outcome is deny.`); } - return Object.freeze({ outcome: 'deny', reason: value.reason }); + return Object.freeze({ outcome: 'deny', reason: value.reason, ...handlerContext(value) }); } default: { const exhaustive: never = outcome; diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index 11246e2ff..ec5a97f13 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -1137,15 +1137,22 @@ export const projectEventHandlerResult = ( target: string, nativeEvent: string, nativeInput?: Readonly>, -): Readonly> | undefined => projectEventDocument( - { - root: { children: [], kind: 'result' }, - status: 'success', - value: result, - version: 1, - }, - event, - target, - nativeEvent, - nativeInput, -); +): Readonly> | undefined => { + const { additionalContext } = result; + return projectEventDocument( + { + root: { children: additionalContext === undefined ? [] : [{ kind: 'context', text: additionalContext }], kind: 'result' }, + status: 'success', + value: result.outcome === 'deny' + ? { outcome: result.outcome, reason: result.reason } + : result.updatedInput === undefined + ? { outcome: result.outcome } + : { outcome: result.outcome, updatedInput: result.updatedInput }, + version: 1, + }, + event, + target, + nativeEvent, + nativeInput, + ); +}; diff --git a/packages/agent-bundle/src/routes/event-definitions.ts b/packages/agent-bundle/src/routes/event-definitions.ts index df3ad9053..06a6b525c 100644 --- a/packages/agent-bundle/src/routes/event-definitions.ts +++ b/packages/agent-bundle/src/routes/event-definitions.ts @@ -8,8 +8,8 @@ import type { JsonValue } from '../core/strict-json.ts'; export type EventResult = | Extract | void - | { readonly outcome: 'continue' } - | (Event extends DenyingEvent ? { readonly outcome: 'deny'; readonly reason: string } : never); + | Extract, { outcome: 'continue' }> + | (Event extends DenyingEvent ? Extract : never); export type EventContext = AgentEventRouteProps & Pick & { readonly render: (module: string, data: JsonValue) => Extract; diff --git a/packages/agent-bundle/tests/event-handler-artifact-graph.test.ts b/packages/agent-bundle/tests/event-handler-artifact-graph.test.ts index 50db004f7..aa0f00549 100644 --- a/packages/agent-bundle/tests/event-handler-artifact-graph.test.ts +++ b/packages/agent-bundle/tests/event-handler-artifact-graph.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, realpath, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, realpath, rename, symlink, writeFile } from 'node:fs/promises'; import { isBuiltin } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, relative } from 'node:path'; @@ -64,6 +64,7 @@ const projectFiles: Readonly> = { 'export default ({ canonical, render }) => {', " const tool = canonical.payload?.['toolInput'] as { readonly value?: { readonly command?: unknown } } | undefined;", " const command = typeof tool?.value?.command === 'string' ? tool.value.command : '';", + " if (command === 'rewrite') return { outcome: 'continue', updatedInput: { command: 'cargo check --locked' }, additionalContext: 'Checking.' };", " if (mentionsCargo(command)) return render('./before.view.js', { ticket: 'cc-7' });", " return command === 'blocked' ? { outcome: 'deny', reason: HANDLER_LEAF_SENTINEL } : { outcome: 'continue' };", '};', @@ -298,6 +299,21 @@ describe('handler artifact graph (#595)', () => { }); }); + it('returns rewrites and context even when the deferred executor is unavailable', async () => { + const executor = join(artifactRoot, entryPath.replace(/\.mjs$/u, '.execute.mjs')); + await rename(executor, `${executor}.disabled`); + try { + const rewritten = await invoke('rewrite'); + expect(rewritten.code).toBe(0); + expect(rewritten.stderr).toBe(''); + expect(JSON.parse(rewritten.stdout)).toEqual({ hookSpecificOutput: { + hookEventName: 'PreToolUse', updatedInput: { command: 'cargo check --locked' }, additionalContext: 'Checking.', + } }); + } finally { + await rename(`${executor}.disabled`, executor); + } + }); + it('passes computed handler data to the deferred route', async () => { const executed = await invoke('cargo check'); expect(executed.code).toBe(0); diff --git a/packages/agent-bundle/tests/event-handler.test.ts b/packages/agent-bundle/tests/event-handler.test.ts index 519536704..185c7d052 100644 --- a/packages/agent-bundle/tests/event-handler.test.ts +++ b/packages/agent-bundle/tests/event-handler.test.ts @@ -5,7 +5,7 @@ import { validateEventHandlerResult, type EventHandlerContext, } from '../src/events/handler.ts'; -import { projectEventHandlerResult } from '../src/events/projection.ts'; +import { projectEventDocument, projectEventHandlerResult } from '../src/events/projection.ts'; import { eventContracts } from '../src/routes/events.ts'; import { canonicalAgentEvents, type CanonicalAgentEvent } from '../src/routes/public.ts'; @@ -165,3 +165,43 @@ it('projects a gate decision through the rendered event outcome rules', () => { }, }); }); + + +it('snapshots strict JSON rewrites and rejects invalid direct-result fields', () => { + const updatedInput = { command: 'cargo check --locked', nested: { args: ['--locked'] } }; + const result = validateEventHandlerResult({ outcome: 'continue', updatedInput, additionalContext: 'Checking.' }, 'tool/before'); + updatedInput.nested.args.push('--release'); + expect(result).toEqual({ outcome: 'continue', updatedInput: { command: 'cargo check --locked', nested: { args: ['--locked'] } }, additionalContext: 'Checking.' }); + if (result.outcome !== 'continue') throw new Error('Expected continue.'); + expect(Object.isFrozen(result.updatedInput)).toBe(true); + for (const invalid of [[], null, 'command', new Date(), { command: undefined }, { command: NaN }]) { + expect(() => validateEventHandlerResult({ outcome: 'continue', updatedInput: invalid }, 'tool/before')).toThrow(); + } + expect(() => validateEventHandlerResult({ outcome: 'continue', updatedInput: {} }, 'tool/after')).toThrow(/tool\/before/u); + expect(() => validateEventHandlerResult({ outcome: 'continue', additionalContext: {} }, 'tool/after')).toThrow(/must be a string/u); + expect(() => validateEventHandlerResult({ outcome: 'deny', reason: 'blocked', additionalContext: 1 }, 'tool/before')).toThrow(/must be a string/u); +}); + +it('projects direct rewrites and context exactly like rendered results on each host', () => { + for (const [event, nativeEvent] of [['tool/before', 'PreToolUse'], ['tool/after', 'PostToolUse']] as const) { + const result = validateEventHandlerResult({ outcome: 'continue', additionalContext: 'Notice.', ...(event === 'tool/before' ? { updatedInput: { command: 'cargo check --locked' } } : {}) }, event); + if (result.outcome === 'render') throw new Error('Unexpected render.'); + for (const target of ['claude', 'codex', 'cursor']) { + const rendered = projectEventDocument({ + root: { kind: 'result', children: [{ kind: 'context', text: 'Notice.' }] }, + value: event === 'tool/before' + ? { outcome: 'continue', updatedInput: { command: 'cargo check --locked' } } + : { outcome: 'continue' }, + status: 'success', + version: 1, + }, event, target, nativeEvent); + expect(projectEventHandlerResult(result, event, target, nativeEvent)).toEqual(rendered); + } + } + expect(projectEventHandlerResult({ outcome: 'continue', updatedInput: { command: 'cargo check' } }, 'tool/before', 'cursor', 'preToolUse')) + .toEqual({ permission: 'allow', updated_input: { command: 'cargo check' } }); + expect(projectEventHandlerResult({ outcome: 'continue', additionalContext: 'Notice.' }, 'tool/after', 'claude', 'PostToolUse')) + .toEqual({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: 'Notice.' } }); + expect(() => projectEventHandlerResult({ outcome: 'continue', additionalContext: 'Notice.' }, 'agent/start', 'cursor', 'subagentStart')) + .toThrow(/no additional-context channel/u); +}); diff --git a/packages/agent-bundle/tests/function-authoring.test.ts b/packages/agent-bundle/tests/function-authoring.test.ts index 2de648faa..43a484d5d 100644 --- a/packages/agent-bundle/tests/function-authoring.test.ts +++ b/packages/agent-bundle/tests/function-authoring.test.ts @@ -7,11 +7,21 @@ import { validateRouteModuleContract } from '../src/routes/contract.ts'; import { defineTool, normalizeRouteModule } from '../src/routes/definitions.ts'; import { parseModule } from '../src/routes/syntax.ts'; import { readRouteDefinition } from '../src/routes/definition-syntax.ts'; -import { events } from '../src/routes/event-definitions.ts'; +import { events, type EventResult } from '../src/routes/event-definitions.ts'; import { executeEventHandler } from '../src/events/handler.ts'; import { createCanonicalEventProps, projectEventHandlerResult } from '../src/events/projection.ts'; const path = '/project/src/mcp/runtime/tools/status.tsx'; + +it('types direct event results according to the event family and outcome', () => { + const rewrite = { outcome: 'continue', updatedInput: { command: 'cargo check' }, additionalContext: 'Checking.' } satisfies EventResult<'tool/before'>; + const context = { outcome: 'continue', additionalContext: 'Finished.' } satisfies EventResult<'tool/after'>; + // @ts-expect-error Only tool/before can rewrite pending input. + const afterRewrite: EventResult<'tool/after'> = { outcome: 'continue', updatedInput: {} }; + // @ts-expect-error A denial cannot also rewrite input. + const deniedRewrite: EventResult<'tool/before'> = { outcome: 'deny', reason: 'Blocked.', updatedInput: {} }; + expect([rewrite, context, afterRewrite, deniedRewrite]).toHaveLength(4); +}); const source = ` import { defineTool as tool } from 'agent-bundle/routes'; import { z } from 'zod'; diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index b4f5e2cdc..2d62f5be8 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -80,6 +80,23 @@ The helper infers the canonical payload and family-specific outcomes; its event the file path. A plain default function with the same context and result is also supported. Use `EventHandler<'tool/before'>` when annotating that function. +Return `updatedInput` directly on a continuing `tool/before` handler to rewrite the pending +input. Return `additionalContext` on a continuing or denying handler for a short context +message. These results use the same host projection as JSX and start no render worker; +`updatedInput` must be a strict JSON object and cannot accompany a denial. Context channels +remain host-specific: Cursor drops context on `tool/before` but accepts it on `tool/after`; +Claude and Codex accept context on both. Use `requires` or `targets` for other event families +whose hosts differ. A rewrite preserves the host permission flow on Claude and Codex; Cursor +requires `permission: 'allow'` alongside `updated_input`. + +```ts +export default events.tool.before({}, () => ({ + outcome: 'continue', + updatedInput: { command: 'cargo check --locked' }, + additionalContext: 'Checking with the lockfile.', +})); +``` + For a cheap gate followed by JSX, put the view in a separate sibling module: ```ts diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index 1098b0b31..6770598aa 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -73,6 +73,22 @@ export default events.tool.before({}, async ({ canonical, provider }) => { 辅助函数推断该事件的载荷与允许的结果,事件名称必须与文件路径一致。也可使用普通默认函数,并用 `EventHandler<'tool/before'>` 标注类型。 +在 `tool/before` 处理器的继续结果中直接返回 `updatedInput`,即可改写待执行的输入。 +继续或拒绝结果可以带 `additionalContext`,用于简短的上下文消息。这些结果与 JSX 使用相同的 +宿主投影,不启动渲染工作进程。`updatedInput` 必须是严格 JSON 对象,且不能与拒绝结果一起返回。 +上下文通道仍取决于宿主:Cursor 忽略 `tool/before` 的上下文,但接受 `tool/after` 的上下文; +Claude 和 Codex 在两者上都接受上下文。其他存在宿主差异的事件可使用 `requires` 或 `targets`。 +改写在 Claude 和 Codex 上保留宿主权限流程;Cursor 要求 `updated_input` 同时携带 +`permission: 'allow'`。 + +```ts +export default events.tool.before({}, () => ({ + outcome: 'continue', + updatedInput: { command: 'cargo check --locked' }, + additionalContext: 'Checking with the lockfile.', +})); +``` + 需要先判断再渲染时,将 JSX 放入独立的同级模块: ```ts From 9fbd5c9fed6acf213ef4d116febf76829fd97d25 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 4 Oct 2026 10:09:48 +0000 Subject: [PATCH 2/4] docs: reference event result fix in changeset --- .changeset/direct-event-results.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/direct-event-results.md b/.changeset/direct-event-results.md index 1781c507d..37c96956f 100644 --- a/.changeset/direct-event-results.md +++ b/.changeset/direct-event-results.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Return `updatedInput` and `additionalContext` directly from `events` handlers without starting a render worker. +Return `updatedInput` and `additionalContext` directly from `events` handlers without starting a render worker. (#873) From 43128916b8e58a71639d44a3feea23ab3c9ddaf1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 4 Oct 2026 10:13:03 +0000 Subject: [PATCH 3/4] fix: reject input rewrite fields on other event families --- packages/agent-bundle/src/events/handler.ts | 4 ++-- packages/agent-bundle/tests/event-handler.test.ts | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/agent-bundle/src/events/handler.ts b/packages/agent-bundle/src/events/handler.ts index 1c78a4a9d..dce2390da 100644 --- a/packages/agent-bundle/src/events/handler.ts +++ b/packages/agent-bundle/src/events/handler.ts @@ -79,9 +79,9 @@ export const validateEventHandlerResult = ( if (view === undefined || value.module !== view) throw new TypeError('ctx.render() must name the event handler’s sibling .view.js module.'); return Object.freeze({ data: snapshotStrictJsonValue(value.data), module: view, outcome: 'render' }); case 'continue': { - unexpectedFields(value, new Set(['outcome', 'updatedInput', 'additionalContext'])); + unexpectedFields(value, new Set(['outcome', 'additionalContext', ...(event === 'tool/before' ? ['updatedInput'] : [])])); const updatedInput = value.updatedInput === undefined ? undefined : snapshotStrictJsonValue(value.updatedInput); - if (updatedInput !== undefined && (event !== 'tool/before' || !isJsonRecord(updatedInput))) { + if (updatedInput !== undefined && !isJsonRecord(updatedInput)) { throw new TypeError('Event handler updatedInput must be an object on tool/before.'); } return Object.freeze({ diff --git a/packages/agent-bundle/tests/event-handler.test.ts b/packages/agent-bundle/tests/event-handler.test.ts index 185c7d052..9a61be120 100644 --- a/packages/agent-bundle/tests/event-handler.test.ts +++ b/packages/agent-bundle/tests/event-handler.test.ts @@ -177,7 +177,9 @@ it('snapshots strict JSON rewrites and rejects invalid direct-result fields', () for (const invalid of [[], null, 'command', new Date(), { command: undefined }, { command: NaN }]) { expect(() => validateEventHandlerResult({ outcome: 'continue', updatedInput: invalid }, 'tool/before')).toThrow(); } - expect(() => validateEventHandlerResult({ outcome: 'continue', updatedInput: {} }, 'tool/after')).toThrow(/tool\/before/u); + for (const updatedInput of [{}, undefined]) { + expect(() => validateEventHandlerResult({ outcome: 'continue', updatedInput }, 'tool/after')).toThrow(/unsupported field/u); + } expect(() => validateEventHandlerResult({ outcome: 'continue', additionalContext: {} }, 'tool/after')).toThrow(/must be a string/u); expect(() => validateEventHandlerResult({ outcome: 'deny', reason: 'blocked', additionalContext: 1 }, 'tool/before')).toThrow(/must be a string/u); }); @@ -202,6 +204,15 @@ it('projects direct rewrites and context exactly like rendered results on each h .toEqual({ permission: 'allow', updated_input: { command: 'cargo check' } }); expect(projectEventHandlerResult({ outcome: 'continue', additionalContext: 'Notice.' }, 'tool/after', 'claude', 'PostToolUse')) .toEqual({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: 'Notice.' } }); + const denial = validateEventHandlerResult({ outcome: 'deny', reason: 'Blocked.', additionalContext: 'Notice.' }, 'tool/before'); + if (denial.outcome !== 'deny') throw new Error('Expected denial.'); + for (const target of ['claude', 'codex']) { + expect(projectEventHandlerResult(denial, 'tool/before', target, 'PreToolUse')).toEqual({ hookSpecificOutput: { + hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Blocked.', additionalContext: 'Notice.', + } }); + } + expect(projectEventHandlerResult({ outcome: 'continue', updatedInput: { command: 'cargo check' } }, 'tool/before', 'amp', 'tool.before')) + .toEqual({ action: 'modify', input: { command: 'cargo check' } }); expect(() => projectEventHandlerResult({ outcome: 'continue', additionalContext: 'Notice.' }, 'agent/start', 'cursor', 'subagentStart')) .toThrow(/no additional-context channel/u); }); From 62bd459247527e45254e751f705b45eb78b3d206 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 4 Oct 2026 10:13:31 +0000 Subject: [PATCH 4/4] test: use Amp native tool event in rewrite coverage --- packages/agent-bundle/tests/event-handler.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-bundle/tests/event-handler.test.ts b/packages/agent-bundle/tests/event-handler.test.ts index 9a61be120..1b7336847 100644 --- a/packages/agent-bundle/tests/event-handler.test.ts +++ b/packages/agent-bundle/tests/event-handler.test.ts @@ -211,7 +211,7 @@ it('projects direct rewrites and context exactly like rendered results on each h hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Blocked.', additionalContext: 'Notice.', } }); } - expect(projectEventHandlerResult({ outcome: 'continue', updatedInput: { command: 'cargo check' } }, 'tool/before', 'amp', 'tool.before')) + expect(projectEventHandlerResult({ outcome: 'continue', updatedInput: { command: 'cargo check' } }, 'tool/before', 'amp', 'tool.call')) .toEqual({ action: 'modify', input: { command: 'cargo check' } }); expect(() => projectEventHandlerResult({ outcome: 'continue', additionalContext: 'Notice.' }, 'agent/start', 'cursor', 'subagentStart')) .toThrow(/no additional-context channel/u);