* feat: implement Hook Chains runtime integration for self-healing agent mesh MVP - Add Hook Chains config loader, evaluator, and dispatcher in src/utils/hookChains.ts - Wire PostToolUseFailure hook dispatch in executePostToolUseFailureHooks() - Wire TaskCompleted hook dispatch in executeTaskCompletedHooks() - Integrate fallback-agent launcher with permission preservation (canUseTool threading) - Add safety hardening for config-read errors (try-catch protection) - Update docs with MVP runtime trigger explanation - Add 10 unit tests and 4 integration tests covering config, rules, guards, and actions This completes the self-healing agent mesh MVP by enabling declarative rule-based responses to tool failures and task completions, with fallback agent spawning, team notification, and capacity warming actions. * Update docs/hook-chains.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/utils/hookChains.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: address PR #711 review blockers for Hook Chains - Gate hook-chain dispatch behind feature('HOOK_CHAINS') and default env gate to off - Remove committed local artifact (agent.log) and ignore it in .gitignore - Revert hook dispatcher signature threading changes for canUseTool - Use ToolUseContext metadata hookChainsCanUseTool for fallback launch permissions - Make spawn_fallback_agent fail explicitly when launcher context is unavailable - Add config cache max age and guard map size limits to bound runtime memory - Update docs and tests for default-off gating and explicit fallback failure --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
477 lines
14 KiB
TypeScript
477 lines
14 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
|
|
type HookChainsModule = typeof import('./hookChains.js')
|
|
|
|
const tempDirs: string[] = []
|
|
const originalHookChainsEnabled = process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS
|
|
|
|
async function makeConfigFile(config: unknown): Promise<string> {
|
|
const dir = await mkdtemp(join(tmpdir(), 'openclaude-hook-chains-'))
|
|
tempDirs.push(dir)
|
|
const filePath = join(dir, 'hook-chains.json')
|
|
await writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8')
|
|
return filePath
|
|
}
|
|
|
|
async function importHookChainsModule(options?: {
|
|
allowRemoteSessions?: boolean
|
|
}): Promise<HookChainsModule> {
|
|
mock.restore()
|
|
|
|
const allowRemoteSessions = options?.allowRemoteSessions ?? true
|
|
|
|
mock.module('../services/analytics/index.js', () => ({
|
|
logEvent: () => {},
|
|
}))
|
|
|
|
mock.module('./telemetry/events.js', () => ({
|
|
logOTelEvent: async () => {},
|
|
}))
|
|
|
|
mock.module('../services/policyLimits/index.js', () => ({
|
|
isPolicyAllowed: () => allowRemoteSessions,
|
|
}))
|
|
|
|
return import(`./hookChains.js?test=${Date.now()}-${Math.random()}`)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = '1'
|
|
})
|
|
|
|
afterEach(async () => {
|
|
mock.restore()
|
|
|
|
if (originalHookChainsEnabled === undefined) {
|
|
delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS
|
|
} else {
|
|
process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = originalHookChainsEnabled
|
|
}
|
|
|
|
await Promise.all(
|
|
tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })),
|
|
)
|
|
})
|
|
|
|
describe('hookChains schema validation', () => {
|
|
test('returns disabled config when env gate is unset', async () => {
|
|
delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS
|
|
const mod = await importHookChainsModule()
|
|
|
|
const configPath = await makeConfigFile({
|
|
version: 1,
|
|
enabled: true,
|
|
rules: [
|
|
{
|
|
id: 'env-gated-rule',
|
|
trigger: { event: 'TaskCompleted', outcome: 'failed' },
|
|
actions: [{ type: 'spawn_fallback_agent' }],
|
|
},
|
|
],
|
|
})
|
|
|
|
const loaded = mod.loadHookChainsConfig({ pathOverride: configPath })
|
|
expect(loaded.exists).toBe(false)
|
|
expect(loaded.config.enabled).toBe(false)
|
|
expect(loaded.config.rules).toHaveLength(0)
|
|
})
|
|
|
|
test('loads valid config and memoizes by mtime/size', async () => {
|
|
const mod = await importHookChainsModule()
|
|
|
|
const configPath = await makeConfigFile({
|
|
version: 1,
|
|
enabled: true,
|
|
maxChainDepth: 3,
|
|
defaultCooldownMs: 5000,
|
|
defaultDedupWindowMs: 5000,
|
|
rules: [
|
|
{
|
|
id: 'task-failure-fallback',
|
|
trigger: { event: 'TaskCompleted', outcome: 'failed' },
|
|
actions: [
|
|
{
|
|
type: 'spawn_fallback_agent',
|
|
description: 'Fallback recovery agent',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
})
|
|
|
|
const first = mod.loadHookChainsConfig({ pathOverride: configPath })
|
|
expect(first.exists).toBe(true)
|
|
expect(first.error).toBeUndefined()
|
|
expect(first.fromCache).toBe(false)
|
|
expect(first.config.enabled).toBe(true)
|
|
expect(first.config.rules).toHaveLength(1)
|
|
expect(first.config.rules[0]?.id).toBe('task-failure-fallback')
|
|
|
|
const second = mod.loadHookChainsConfig({ pathOverride: configPath })
|
|
expect(second.exists).toBe(true)
|
|
expect(second.error).toBeUndefined()
|
|
expect(second.fromCache).toBe(true)
|
|
expect(second.config.rules).toHaveLength(1)
|
|
})
|
|
|
|
test('accepts wrapped { hookChains: ... } config shape', async () => {
|
|
const mod = await importHookChainsModule()
|
|
|
|
const configPath = await makeConfigFile({
|
|
hookChains: {
|
|
version: 1,
|
|
enabled: true,
|
|
rules: [
|
|
{
|
|
id: 'wrapped-shape',
|
|
trigger: { event: 'PostToolUseFailure', outcomes: ['failed'] },
|
|
actions: [{ type: 'notify_team' }],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
|
|
const loaded = mod.loadHookChainsConfig({ pathOverride: configPath })
|
|
expect(loaded.error).toBeUndefined()
|
|
expect(loaded.config.enabled).toBe(true)
|
|
expect(loaded.config.rules[0]?.id).toBe('wrapped-shape')
|
|
})
|
|
|
|
test('returns disabled config for invalid schema', async () => {
|
|
const mod = await importHookChainsModule()
|
|
|
|
const configPath = await makeConfigFile({
|
|
version: 1,
|
|
enabled: true,
|
|
rules: [
|
|
{
|
|
id: 'invalid-rule',
|
|
trigger: {
|
|
event: 'TaskCompleted',
|
|
outcome: 'failed',
|
|
outcomes: ['failed'],
|
|
},
|
|
actions: [{ type: 'spawn_fallback_agent' }],
|
|
},
|
|
],
|
|
})
|
|
|
|
const loaded = mod.loadHookChainsConfig({ pathOverride: configPath })
|
|
expect(loaded.exists).toBe(true)
|
|
expect(loaded.error).toBeDefined()
|
|
expect(loaded.config.enabled).toBe(false)
|
|
expect(loaded.config.rules).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('evaluateHookChainRules', () => {
|
|
test('matches by event + outcome + condition', async () => {
|
|
const mod = await importHookChainsModule()
|
|
|
|
const rules = [
|
|
{
|
|
id: 'post-tool-failure-rule',
|
|
trigger: { event: 'PostToolUseFailure', outcome: 'failed' },
|
|
condition: {
|
|
toolNames: ['Edit'],
|
|
errorIncludes: ['permission'],
|
|
eventFieldEquals: { 'meta.source': 'scheduler' },
|
|
},
|
|
actions: [{ type: 'spawn_fallback_agent' }],
|
|
},
|
|
]
|
|
|
|
const matches = mod.evaluateHookChainRules(rules as never, {
|
|
eventName: 'PostToolUseFailure',
|
|
outcome: 'failed',
|
|
payload: {
|
|
tool_name: 'Edit',
|
|
error: 'Permission denied by policy',
|
|
meta: { source: 'scheduler' },
|
|
},
|
|
})
|
|
|
|
expect(matches).toHaveLength(1)
|
|
expect(matches[0]?.rule.id).toBe('post-tool-failure-rule')
|
|
})
|
|
|
|
test('does not match when event/condition fail', async () => {
|
|
const mod = await importHookChainsModule()
|
|
|
|
const rules = [
|
|
{
|
|
id: 'rule-no-match',
|
|
trigger: { event: 'PostToolUseFailure', outcomes: ['failed'] },
|
|
condition: { toolNames: ['Write'] },
|
|
actions: [{ type: 'spawn_fallback_agent' }],
|
|
},
|
|
]
|
|
|
|
const wrongEvent = mod.evaluateHookChainRules(rules as never, {
|
|
eventName: 'TaskCompleted',
|
|
outcome: 'failed',
|
|
payload: { tool_name: 'Write' },
|
|
})
|
|
expect(wrongEvent).toHaveLength(0)
|
|
|
|
const wrongCondition = mod.evaluateHookChainRules(rules as never, {
|
|
eventName: 'PostToolUseFailure',
|
|
outcome: 'failed',
|
|
payload: { tool_name: 'Edit' },
|
|
})
|
|
expect(wrongCondition).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('dispatchHookChainsForEvent guard logic', () => {
|
|
test('dedup skips duplicate event/action within dedup window', async () => {
|
|
const mod = await importHookChainsModule()
|
|
|
|
const configPath = await makeConfigFile({
|
|
version: 1,
|
|
enabled: true,
|
|
maxChainDepth: 4,
|
|
defaultCooldownMs: 0,
|
|
defaultDedupWindowMs: 60_000,
|
|
rules: [
|
|
{
|
|
id: 'dedup-rule',
|
|
trigger: { event: 'TaskCompleted', outcome: 'failed' },
|
|
cooldownMs: 0,
|
|
dedupWindowMs: 60_000,
|
|
actions: [{ id: 'spawn-1', type: 'spawn_fallback_agent' }],
|
|
},
|
|
],
|
|
})
|
|
|
|
const spawn = mock(async () => ({ launched: true, agentId: 'agent-1' }))
|
|
|
|
const first = await mod.dispatchHookChainsForEvent({
|
|
configPathOverride: configPath,
|
|
event: {
|
|
eventName: 'TaskCompleted',
|
|
outcome: 'failed',
|
|
payload: { task_id: 'task-123', error: 'boom' },
|
|
},
|
|
runtime: { onSpawnFallbackAgent: spawn },
|
|
})
|
|
|
|
const second = await mod.dispatchHookChainsForEvent({
|
|
configPathOverride: configPath,
|
|
event: {
|
|
eventName: 'TaskCompleted',
|
|
outcome: 'failed',
|
|
payload: { task_id: 'task-123', error: 'boom' },
|
|
},
|
|
runtime: { onSpawnFallbackAgent: spawn },
|
|
})
|
|
|
|
expect(first.actionResults[0]?.status).toBe('executed')
|
|
expect(second.actionResults[0]?.status).toBe('skipped')
|
|
expect(second.actionResults[0]?.reason).toContain('dedup')
|
|
expect(spawn).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('cooldown skips second dispatch when rule cooldown is active', async () => {
|
|
const mod = await importHookChainsModule()
|
|
|
|
const configPath = await makeConfigFile({
|
|
version: 1,
|
|
enabled: true,
|
|
maxChainDepth: 4,
|
|
defaultCooldownMs: 60_000,
|
|
defaultDedupWindowMs: 0,
|
|
rules: [
|
|
{
|
|
id: 'cooldown-rule',
|
|
trigger: { event: 'TaskCompleted', outcome: 'failed' },
|
|
cooldownMs: 60_000,
|
|
dedupWindowMs: 0,
|
|
actions: [{ type: 'spawn_fallback_agent' }],
|
|
},
|
|
],
|
|
})
|
|
|
|
const spawn = mock(async () => ({ launched: true, agentId: 'agent-2' }))
|
|
|
|
const first = await mod.dispatchHookChainsForEvent({
|
|
configPathOverride: configPath,
|
|
event: {
|
|
eventName: 'TaskCompleted',
|
|
outcome: 'failed',
|
|
payload: { task_id: 'task-456' },
|
|
},
|
|
runtime: { onSpawnFallbackAgent: spawn },
|
|
})
|
|
|
|
const second = await mod.dispatchHookChainsForEvent({
|
|
configPathOverride: configPath,
|
|
event: {
|
|
eventName: 'TaskCompleted',
|
|
outcome: 'failed',
|
|
payload: { task_id: 'task-789' },
|
|
},
|
|
runtime: { onSpawnFallbackAgent: spawn },
|
|
})
|
|
|
|
expect(first.actionResults[0]?.status).toBe('executed')
|
|
expect(second.actionResults[0]?.status).toBe('skipped')
|
|
expect(second.actionResults[0]?.reason).toContain('cooldown')
|
|
expect(spawn).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('depth limit blocks dispatch when chain depth reaches max', async () => {
|
|
const mod = await importHookChainsModule()
|
|
|
|
const configPath = await makeConfigFile({
|
|
version: 1,
|
|
enabled: true,
|
|
maxChainDepth: 1,
|
|
defaultCooldownMs: 0,
|
|
defaultDedupWindowMs: 0,
|
|
rules: [
|
|
{
|
|
id: 'depth-rule',
|
|
trigger: { event: 'TaskCompleted', outcome: 'failed' },
|
|
actions: [{ type: 'spawn_fallback_agent' }],
|
|
},
|
|
],
|
|
})
|
|
|
|
const spawn = mock(async () => ({ launched: true, agentId: 'agent-3' }))
|
|
|
|
const result = await mod.dispatchHookChainsForEvent({
|
|
configPathOverride: configPath,
|
|
event: {
|
|
eventName: 'TaskCompleted',
|
|
outcome: 'failed',
|
|
payload: { task_id: 'task-depth' },
|
|
},
|
|
runtime: {
|
|
chainDepth: 1,
|
|
onSpawnFallbackAgent: spawn,
|
|
},
|
|
})
|
|
|
|
expect(result.enabled).toBe(true)
|
|
expect(result.matchedRuleIds).toHaveLength(0)
|
|
expect(result.actionResults).toHaveLength(0)
|
|
expect(spawn).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('action dispatch skip scenarios', () => {
|
|
test('fails spawn_fallback_agent when launcher callback is missing', async () => {
|
|
const mod = await importHookChainsModule()
|
|
|
|
const configPath = await makeConfigFile({
|
|
version: 1,
|
|
enabled: true,
|
|
maxChainDepth: 3,
|
|
defaultCooldownMs: 0,
|
|
defaultDedupWindowMs: 0,
|
|
rules: [
|
|
{
|
|
id: 'missing-launcher',
|
|
trigger: { event: 'TaskCompleted', outcome: 'failed' },
|
|
actions: [{ type: 'spawn_fallback_agent' }],
|
|
},
|
|
],
|
|
})
|
|
|
|
const result = await mod.dispatchHookChainsForEvent({
|
|
configPathOverride: configPath,
|
|
event: {
|
|
eventName: 'TaskCompleted',
|
|
outcome: 'failed',
|
|
payload: { task_id: 'task-missing-launcher' },
|
|
},
|
|
runtime: {},
|
|
})
|
|
|
|
expect(result.actionResults[0]?.status).toBe('failed')
|
|
expect(result.actionResults[0]?.reason).toContain('launcher')
|
|
})
|
|
|
|
test('skips disabled action and does not execute callback', async () => {
|
|
const mod = await importHookChainsModule()
|
|
|
|
const configPath = await makeConfigFile({
|
|
version: 1,
|
|
enabled: true,
|
|
maxChainDepth: 3,
|
|
defaultCooldownMs: 0,
|
|
defaultDedupWindowMs: 0,
|
|
rules: [
|
|
{
|
|
id: 'disabled-action-rule',
|
|
trigger: { event: 'TaskCompleted', outcome: 'failed' },
|
|
actions: [
|
|
{
|
|
type: 'spawn_fallback_agent',
|
|
enabled: false,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
})
|
|
|
|
const spawn = mock(async () => ({ launched: true, agentId: 'agent-4' }))
|
|
|
|
const result = await mod.dispatchHookChainsForEvent({
|
|
configPathOverride: configPath,
|
|
event: {
|
|
eventName: 'TaskCompleted',
|
|
outcome: 'failed',
|
|
payload: { task_id: 'task-disabled' },
|
|
},
|
|
runtime: { onSpawnFallbackAgent: spawn },
|
|
})
|
|
|
|
expect(result.actionResults[0]?.status).toBe('skipped')
|
|
expect(result.actionResults[0]?.reason).toContain('disabled')
|
|
expect(spawn).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('skips warm_remote_capacity when policy denies remote sessions', async () => {
|
|
const mod = await importHookChainsModule({ allowRemoteSessions: false })
|
|
|
|
const configPath = await makeConfigFile({
|
|
version: 1,
|
|
enabled: true,
|
|
maxChainDepth: 3,
|
|
defaultCooldownMs: 0,
|
|
defaultDedupWindowMs: 0,
|
|
rules: [
|
|
{
|
|
id: 'policy-denied-remote-warm',
|
|
trigger: { event: 'TaskCompleted', outcome: 'failed' },
|
|
actions: [{ type: 'warm_remote_capacity' }],
|
|
},
|
|
],
|
|
})
|
|
|
|
const warm = mock(async () => ({
|
|
warmed: true,
|
|
environmentId: 'env-123',
|
|
}))
|
|
|
|
const result = await mod.dispatchHookChainsForEvent({
|
|
configPathOverride: configPath,
|
|
event: {
|
|
eventName: 'TaskCompleted',
|
|
outcome: 'failed',
|
|
payload: { task_id: 'task-policy-denied' },
|
|
},
|
|
runtime: { onWarmRemoteCapacity: warm },
|
|
})
|
|
|
|
expect(result.actionResults[0]?.status).toBe('skipped')
|
|
expect(result.actionResults[0]?.reason).toContain('policy')
|
|
expect(warm).not.toHaveBeenCalled()
|
|
})
|
|
})
|