|
| 1 | +import { readFile } from 'node:fs/promises'; |
| 2 | + |
| 3 | +import { expect, it } from '@rstest/core'; |
| 4 | + |
| 5 | +import claudeCapabilityTable from '../src/adapters/capabilities/claude-2.1.250.json' with { type: 'json' }; |
| 6 | +import { claudeAdapter } from '../src/adapters/claude.ts'; |
| 7 | +import { pluginAdapter } from '../src/adapters/plugin.ts'; |
| 8 | +import type { NormalizedHook, NormalizedHookEvent, NormalizedPlugin } from '../src/core/types.ts'; |
| 9 | +import { validateNativeEventEnvelope } from '../src/events/projection.ts'; |
| 10 | +import type { CanonicalAgentEvent } from '../src/routes/public.ts'; |
| 11 | + |
| 12 | +const configPath = '/workspace/agent-bundle.config.ts'; |
| 13 | + |
| 14 | +/** |
| 15 | + * Every event route the pinned Claude capability table supports, paired with |
| 16 | + * the hook identity `normalizeProject` assigns it and a native envelope of the |
| 17 | + * shape Claude Code writes to the wrapper's stdin. Fixture-backed rows reuse |
| 18 | + * the documented envelopes under `fixtures/events/`; the four inline rows are |
| 19 | + * the events Claude Code fires on every session (the maintainer's live |
| 20 | + * session below reproduced exactly these). |
| 21 | + */ |
| 22 | +const claudeEventRoutes: readonly { |
| 23 | + readonly hookEvent: NormalizedHookEvent; |
| 24 | + readonly native: string | Readonly<Record<string, unknown>>; |
| 25 | + readonly route: CanonicalAgentEvent; |
| 26 | +}[] = [ |
| 27 | + { hookEvent: 'agentIdle', native: 'claude-teammate-idle.json', route: 'agent/idle' }, |
| 28 | + { hookEvent: 'agentStart', native: 'claude-subagent-start.json', route: 'agent/start' }, |
| 29 | + { hookEvent: 'agentStop', native: 'claude-subagent-stop.json', route: 'agent/stop' }, |
| 30 | + { hookEvent: 'compactAfter', native: 'claude-post-compact.json', route: 'compact/after' }, |
| 31 | + { hookEvent: 'compactBefore', native: 'claude-pre-compact.json', route: 'compact/before' }, |
| 32 | + { hookEvent: 'configChange', native: 'claude-config-change.json', route: 'config/change' }, |
| 33 | + { hookEvent: 'fileChange', native: 'claude-file-changed.json', route: 'file/change' }, |
| 34 | + { hookEvent: 'permissionDenied', native: 'claude-permission-denied.json', route: 'permission/denied' }, |
| 35 | + { hookEvent: 'permissionRequest', native: 'claude-permission-request.json', route: 'permission/request' }, |
| 36 | + { hookEvent: 'promptSubmit', native: 'claude-user-prompt-submit.json', route: 'prompt/submit' }, |
| 37 | + { hookEvent: 'sessionEnd', native: 'claude-session-end.json', route: 'session/end' }, |
| 38 | + { |
| 39 | + hookEvent: 'sessionStart', |
| 40 | + native: { |
| 41 | + cwd: '/workspace', |
| 42 | + hook_event_name: 'SessionStart', |
| 43 | + session_id: 'session-claude-1', |
| 44 | + source: 'startup', |
| 45 | + transcript_path: '/workspace/.claude/projects/session.jsonl', |
| 46 | + }, |
| 47 | + route: 'session/start', |
| 48 | + }, |
| 49 | + { |
| 50 | + hookEvent: 'stop', |
| 51 | + native: { |
| 52 | + cwd: '/workspace', |
| 53 | + hook_event_name: 'Stop', |
| 54 | + last_assistant_message: 'Done.', |
| 55 | + permission_mode: 'default', |
| 56 | + session_id: 'session-claude-1', |
| 57 | + stop_hook_active: false, |
| 58 | + transcript_path: '/workspace/.claude/projects/session.jsonl', |
| 59 | + }, |
| 60 | + route: 'stop', |
| 61 | + }, |
| 62 | + { hookEvent: 'stopFailure', native: 'claude-stop-failure.json', route: 'stop/failure' }, |
| 63 | + { hookEvent: 'taskComplete', native: 'claude-task-completed.json', route: 'task/complete' }, |
| 64 | + { hookEvent: 'taskCreate', native: 'claude-task-created.json', route: 'task/create' }, |
| 65 | + { |
| 66 | + hookEvent: 'afterTool', |
| 67 | + native: { |
| 68 | + cwd: '/workspace', |
| 69 | + hook_event_name: 'PostToolUse', |
| 70 | + permission_mode: 'bypassPermissions', |
| 71 | + session_id: 'session-claude-1', |
| 72 | + tool_input: { command: 'git status --short', description: 'Show the working tree' }, |
| 73 | + tool_name: 'Bash', |
| 74 | + tool_response: { interrupted: false, isImage: false, stderr: '', stdout: ' M README.md\n' }, |
| 75 | + tool_use_id: 'toolu_01LvwxiKhvU7wJ1Hf2MUJ2hu', |
| 76 | + transcript_path: '/workspace/.claude/projects/session.jsonl', |
| 77 | + }, |
| 78 | + route: 'tool/after', |
| 79 | + }, |
| 80 | + { |
| 81 | + hookEvent: 'beforeTool', |
| 82 | + native: { |
| 83 | + cwd: '/workspace', |
| 84 | + hook_event_name: 'PreToolUse', |
| 85 | + permission_mode: 'bypassPermissions', |
| 86 | + session_id: 'session-claude-1', |
| 87 | + tool_input: { command: 'git status --short', description: 'Show the working tree' }, |
| 88 | + tool_name: 'Bash', |
| 89 | + tool_use_id: 'toolu_01Taws9XLqrL8XQk4BsTkjps', |
| 90 | + transcript_path: '/workspace/.claude/projects/session.jsonl', |
| 91 | + }, |
| 92 | + route: 'tool/before', |
| 93 | + }, |
| 94 | + { hookEvent: 'toolFailure', native: 'claude-post-tool-use-failure.json', route: 'tool/failure' }, |
| 95 | +]; |
| 96 | + |
| 97 | +const pinnedClaudeRoutes: Readonly<Record<string, { readonly nativeEvent?: string; readonly state: string }>> = |
| 98 | + claudeCapabilityTable.hooks.eventRoutes; |
| 99 | + |
| 100 | +const supportedClaudeRoutes = Object.entries(pinnedClaudeRoutes) |
| 101 | + .filter(([, capability]) => capability.state === 'supported') |
| 102 | + .map(([route]) => route) |
| 103 | + .sort(); |
| 104 | + |
| 105 | +const routeHook = ( |
| 106 | + route: CanonicalAgentEvent, |
| 107 | + hookEvent: NormalizedHookEvent, |
| 108 | + targets: readonly string[], |
| 109 | +): NormalizedHook => { |
| 110 | + const name = `event-route-${route.replace('/', '-')}`; |
| 111 | + return { |
| 112 | + event: hookEvent, |
| 113 | + eventRoute: { event: route, fallback: 'none', runtime: 'shared' }, |
| 114 | + id: `hook:${name}`, |
| 115 | + name, |
| 116 | + provenance: { kind: 'conventional', sourcePath: `/workspace/src/events/${route}.tsx` }, |
| 117 | + source: `/workspace/src/events/${route}.tsx`, |
| 118 | + targets, |
| 119 | + tools: [], |
| 120 | + }; |
| 121 | +}; |
| 122 | + |
| 123 | +const model = (target: string, hooks: readonly NormalizedHook[]): NormalizedPlugin => ({ |
| 124 | + extensions: {}, |
| 125 | + hooks, |
| 126 | + mcpServers: [], |
| 127 | + metadata: { |
| 128 | + description: 'Claude hook_event_name regression.', |
| 129 | + id: 'plugin:hook-event-name', |
| 130 | + name: 'hook-event-name', |
| 131 | + provenance: { kind: 'config', sourcePath: configPath }, |
| 132 | + version: '1.0.0', |
| 133 | + }, |
| 134 | + runtime: { node: '22.12.0' }, |
| 135 | + scripts: [], |
| 136 | + skills: [], |
| 137 | + targets: [{ |
| 138 | + id: `target:${target}`, |
| 139 | + name: target, |
| 140 | + provenance: { kind: 'config', sourcePath: configPath }, |
| 141 | + }], |
| 142 | +}); |
| 143 | + |
| 144 | +const nativeEnvelope = async ( |
| 145 | + native: string | Readonly<Record<string, unknown>>, |
| 146 | +): Promise<Readonly<Record<string, unknown>>> => |
| 147 | + typeof native === 'string' |
| 148 | + ? JSON.parse(await readFile(new URL(`./fixtures/events/${native}`, import.meta.url), 'utf8')) as Record<string, unknown> |
| 149 | + : native; |
| 150 | + |
| 151 | +const writes = (plan: { readonly entries: readonly { readonly kind: string; readonly relativePath: string; readonly content?: string }[] }) => |
| 152 | + Object.fromEntries(plan.entries.flatMap((entry) => entry.kind === 'write' ? [[entry.relativePath, entry.content!]] : [])); |
| 153 | + |
| 154 | +it('covers every event route the pinned Claude capability table supports', () => { |
| 155 | + expect(claudeEventRoutes.map((entry) => entry.route).sort()).toEqual(supportedClaudeRoutes); |
| 156 | +}); |
| 157 | + |
| 158 | +it('bakes the pinned Claude hook_event_name into every Claude event-route wrapper and accepts the native envelope', async () => { |
| 159 | + const hooks = claudeEventRoutes.map((entry) => routeHook(entry.route, entry.hookEvent, ['claude'])); |
| 160 | + const plan = claudeAdapter.plan(model('claude', hooks)); |
| 161 | + expect(plan.diagnostics).toEqual([]); |
| 162 | + |
| 163 | + const document = JSON.parse(writes(plan)['hooks/hooks.json']!) as { readonly hooks: Record<string, unknown> }; |
| 164 | + for (const entry of claudeEventRoutes) { |
| 165 | + const expectedNativeEvent = pinnedClaudeRoutes[entry.route]?.nativeEvent; |
| 166 | + expect(expectedNativeEvent, entry.route).toEqual(expect.any(String)); |
| 167 | + const wrapper = (plan.hookEntries ?? []).find((candidate) => candidate.hook.eventRoute?.event === entry.route); |
| 168 | + expect(wrapper, entry.route).toBeDefined(); |
| 169 | + // The document key, the baked constant, and the runtime comparison all |
| 170 | + // carry Claude's PascalCase spelling; the wrapper compares the envelope's |
| 171 | + // hook_event_name against exactly that constant. |
| 172 | + expect(Object.keys(document.hooks), entry.route).toContain(expectedNativeEvent); |
| 173 | + expect(wrapper!.nativeEvent, entry.route).toBe(expectedNativeEvent); |
| 174 | + expect(wrapper!.virtualSource, entry.route).toContain(`const nativeEvent = ${JSON.stringify(expectedNativeEvent)};`); |
| 175 | + expect(wrapper!.virtualSource, entry.route).toContain('const artifactTarget = "claude";'); |
| 176 | + expect(wrapper!.virtualSource, entry.route).toContain('const target = artifactTarget;'); |
| 177 | + expect(wrapper!.virtualSource, entry.route).toContain('validateNativeEventEnvelope(parsed, { canonicalEvent, nativeEvent, target })'); |
| 178 | + |
| 179 | + const native = await nativeEnvelope(entry.native); |
| 180 | + expect(native.hook_event_name, entry.route).toBe(expectedNativeEvent); |
| 181 | + expect( |
| 182 | + validateNativeEventEnvelope(native, { canonicalEvent: entry.route, nativeEvent: wrapper!.nativeEvent, target: 'claude' }), |
| 183 | + entry.route, |
| 184 | + ).toEqual(native); |
| 185 | + } |
| 186 | +}); |
| 187 | + |
| 188 | +it('accepts the live PostToolUse:Bash envelope under the Claude wrapper and names a Cursor wrapper as the only source of the observed error', async () => { |
| 189 | + // Regression for the maintainer's Claude Code 2.1.257 session (2026-09-03 |
| 190 | + // 20:47:53Z): every `PostToolUse:Bash` hook failed with |
| 191 | + // "Agent Bundle event route error: native hook_event_name must equal |
| 192 | + // postToolUse". Claude sends PascalCase; only a wrapper compiled for the |
| 193 | + // `cursor` target bakes the camelCase constant, so the message identifies a |
| 194 | + // Cursor-built wrapper installed under a Claude plugin root, not a Claude |
| 195 | + // mapping defect. |
| 196 | + const live = await nativeEnvelope(claudeEventRoutes.find((entry) => entry.route === 'tool/after')!.native); |
| 197 | + const [claudeWrapper] = claudeAdapter.plan(model('claude', [routeHook('tool/after', 'afterTool', ['claude'])])).hookEntries ?? []; |
| 198 | + expect(claudeWrapper?.nativeEvent).toBe('PostToolUse'); |
| 199 | + expect(validateNativeEventEnvelope(live, { canonicalEvent: 'tool/after', nativeEvent: claudeWrapper!.nativeEvent, target: 'claude' })) |
| 200 | + .toEqual(live); |
| 201 | + |
| 202 | + expect(() => validateNativeEventEnvelope(live, { canonicalEvent: 'tool/after', nativeEvent: 'postToolUse', target: 'cursor' })) |
| 203 | + .toThrow('Agent Bundle event route error: native hook_event_name must equal postToolUse'); |
| 204 | + expect(() => validateNativeEventEnvelope( |
| 205 | + { ...live, hook_event_name: 'PreToolUse', tool_response: undefined }, |
| 206 | + { canonicalEvent: 'tool/before', nativeEvent: 'preToolUse', target: 'cursor' }, |
| 207 | + )).toThrow('Agent Bundle event route error: native hook_event_name must equal preToolUse'); |
| 208 | +}); |
| 209 | + |
| 210 | +it('keeps the shared and Cursor wrappers of the unified plugin bundle on their own host spellings', () => { |
| 211 | + const plan = pluginAdapter.plan(model('plugin', [ |
| 212 | + routeHook('tool/after', 'afterTool', ['plugin']), |
| 213 | + routeHook('session/start', 'sessionStart', ['plugin']), |
| 214 | + ])); |
| 215 | + expect(plan.diagnostics).toEqual([]); |
| 216 | + |
| 217 | + const shared = (plan.hookEntries ?? []).find((entry) => |
| 218 | + entry.event === 'afterTool' && !entry.relativePath.endsWith('.cursor.mjs')); |
| 219 | + const cursor = (plan.hookEntries ?? []).find((entry) => |
| 220 | + entry.event === 'afterTool' && entry.relativePath.endsWith('.cursor.mjs')); |
| 221 | + expect(shared?.nativeEvent).toBe('PostToolUse'); |
| 222 | + expect(cursor?.nativeEvent).toBe('postToolUse'); |
| 223 | + expect(shared?.virtualSource).toContain('const nativeEvent = "PostToolUse"'); |
| 224 | + expect(cursor?.virtualSource).toContain('const nativeEvent = "postToolUse"'); |
| 225 | + |
| 226 | + const documents = writes(plan); |
| 227 | + expect(Object.keys((JSON.parse(documents['hooks/hooks.json']!) as { hooks: object }).hooks).sort()).toEqual(['PostToolUse', 'SessionStart']); |
| 228 | + expect(Object.keys((JSON.parse(documents['hooks/hooks-cursor.json']!) as { hooks: object }).hooks).sort()).toEqual(['postToolUse', 'sessionStart']); |
| 229 | +}); |
| 230 | + |
| 231 | +it('emits no manifest hooks pointer for Claude Code, which auto-loads hooks/hooks.json and flags a pointer at it as a duplicate', () => { |
| 232 | + // Claude Code 2.1.259 (observed): `hooks/hooks.json` is loaded on its own |
| 233 | + // and `manifest.hooks` is for additional documents only. Naming the |
| 234 | + // conventional file records a `hook-load-failed` plugin error, "Duplicate |
| 235 | + // hooks file detected ... The standard hooks/hooks.json is loaded |
| 236 | + // automatically, so manifest.hooks should only reference additional hook |
| 237 | + // files." Claude Code never scans `hooks/` for other documents, so the |
| 238 | + // unified bundle's `hooks/hooks-cursor.json` needs no pointer to hide it. |
| 239 | + const claude = writes(claudeAdapter.plan(model('claude', [routeHook('tool/after', 'afterTool', ['claude'])]))); |
| 240 | + expect(claude['hooks/hooks.json']).toBeDefined(); |
| 241 | + expect(JSON.parse(claude['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks'); |
| 242 | + |
| 243 | + const bundle = writes(pluginAdapter.plan(model('plugin', [routeHook('tool/after', 'afterTool', ['plugin'])]))); |
| 244 | + expect(bundle['hooks/hooks.json']).toBeDefined(); |
| 245 | + expect(bundle['hooks/hooks-cursor.json']).toBeDefined(); |
| 246 | + expect(JSON.parse(bundle['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks'); |
| 247 | + // Codex discovers the same conventional file; Cursor's own contract needs |
| 248 | + // the explicit pointer because its document does not live at the default. |
| 249 | + expect(JSON.parse(bundle['.codex-plugin/plugin.json']!)).not.toHaveProperty('hooks'); |
| 250 | + expect(JSON.parse(bundle['.cursor-plugin/plugin.json']!)).toMatchObject({ hooks: './hooks/hooks-cursor.json' }); |
| 251 | +}); |
0 commit comments