qwen-code/integration-tests/hook-integration/hooks.test.ts

6020 lines
196 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, validateModelOutput } from '../test-helper.js';
/**
* Hooks System Integration Tests
*
* Tests for complete hook system flow including:
* - UserPromptSubmit hooks: Triggered before prompt is sent to LLM
* - Stop hooks: Triggered when agent is about to stop
* - SessionStart hooks: Triggered when a new session starts (Startup, Resume, Clear, Compact)
* - SessionEnd hooks: Triggered when a session ends (Clear, Logout, PromptInputExit)
* - PreToolUse hooks: Triggered before tool execution
* - PostToolUse hooks: Triggered after successful tool execution
* - PostToolUseFailure hooks: Triggered after tool execution fails
* - SubagentStart hooks: Triggered when a subagent starts
* - SubagentStop hooks: Triggered when a subagent stops
* - Notification hooks: Triggered when notifications are sent
* - PermissionRequest hooks: Triggered when permission dialogs are displayed
* - PreCompact hooks: Triggered before conversation compaction
*
*/
describe('Hooks System Integration', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => {
if (rig) {
await rig.cleanup();
}
});
// ==========================================================================
// UserPromptSubmit Hooks
// Triggered before user prompt is sent to the LLM for processing
// ==========================================================================
describe('UserPromptSubmit Hooks', () => {
describe('Allow Decision', () => {
it('should allow prompt when hook returns allow decision', async () => {
const hookScript =
'echo \'{"decision": "allow", "reason": "approved by hook"}\'';
await rig.setup('ups-allow-decision', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: hookScript,
name: 'ups-allow-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should allow tool execution with allow decision and verify tool was called', async () => {
const hookScript =
'echo \'{"decision": "allow", "reason": "Tool execution approved"}\'';
await rig.setup('ups-allow-tool', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: hookScript,
name: 'ups-allow-tool-hook',
timeout: 5000,
},
],
},
],
},
},
});
await rig.run('Create a file test.txt with content "hello"');
const foundToolCall = await rig.waitForToolCall('write_file');
expect(foundToolCall).toBeTruthy();
const fileContent = rig.readFile('test.txt');
expect(fileContent).toContain('hello');
});
});
describe('Block Decision', () => {
it('should block prompt when hook returns block decision', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "Prompt blocked by security policy"}\'';
await rig.setup('ups-block-decision', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: blockScript,
name: 'ups-block-hook',
timeout: 5000,
},
],
},
],
},
},
});
// When UserPromptSubmit hook blocks, CLI exits with non-zero code
// and rig.run() throws an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should block tool execution when hook returns block and verify no tool was called', async () => {
const blockScript =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "File writing blocked by security policy"}\'';
await rig.setup('ups-block-tool', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: blockScript,
name: 'ups-block-tool-hook',
timeout: 5000,
},
],
},
],
},
},
});
// When UserPromptSubmit hook blocks, CLI exits with non-zero code
await expect(
rig.run('Create a file test.txt with "hello"'),
).rejects.toThrow(/block/i);
// Tool should not be called due to blocking hook
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter((line) => line.trim() === 'hook_called').length;
expect(hookInvokeCount).toBeGreaterThan(0); // At least one hook call occurred
const toolLogs = rig.readToolLogs();
const writeFileCalls = toolLogs.filter(
(t) =>
t.toolRequest.name === 'write_file' &&
t.toolRequest.success === true,
);
expect(writeFileCalls).toHaveLength(0);
});
});
describe('Modify Prompt', () => {
it('should use modified prompt when hook provides modification', async () => {
const modifyScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "modifiedPrompt": "Modified prompt content", "additionalContext": "Context added by hook"}}\'';
await rig.setup('ups-modify-prompt', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: modifyScript,
name: 'ups-modify-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say test');
expect(result).toBeDefined();
});
});
describe('Additional Context', () => {
it('should include additional context in response when hook provides it', async () => {
const contextScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Extra context information from hook"}}\'';
await rig.setup('ups-add-context', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: contextScript,
name: 'ups-context-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('What is 1+1?');
expect(result).toBeDefined();
});
});
describe('Timeout Handling', () => {
it('should continue execution when hook times out', async () => {
await rig.setup('ups-timeout', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: 'sleep 60',
name: 'ups-timeout-hook',
timeout: 1000,
},
],
},
],
},
},
});
const result = await rig.run('Say timeout test');
// Should continue despite timeout
expect(result).toBeDefined();
});
});
describe('Error Handling', () => {
it('should continue execution when hook exits with non-blocking error (exit code 1)', async () => {
await rig.setup('ups-nonblocking-error', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: 'echo warning && exit 1',
name: 'ups-error-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say error test');
// Non-blocking error should not prevent execution
expect(result).toBeDefined();
});
it('should block execution when hook exits with blocking error (exit code 2)', async () => {
await rig.setup('ups-blocking-error', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: 'echo "Critical security error" >&2 && exit 2',
name: 'ups-blocking-error-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Exit code 2 is a blocking error, so CLI should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should continue execution when hook command is empty', async () => {
await rig.setup('ups-missing-command', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: '',
name: 'ups-missing-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Empty command is ignored, execution continues normally
const result = await rig.run('Say missing test');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
describe('Input Format Validation', () => {
it('should receive properly formatted input when hook is called', async () => {
const inputValidationScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "Valid input format"}}\'';
await rig.setup('ups-correct-input', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: inputValidationScript,
name: 'ups-input-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say input test');
validateModelOutput(result, 'input test', 'UPS: correct input');
});
});
describe('System Message', () => {
it('should include system message in response when hook provides it', async () => {
const systemMsgScript =
'echo \'{"decision": "allow", "systemMessage": "This is a system message from hook"}\'';
await rig.setup('ups-system-message', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: systemMsgScript,
name: 'ups-system-msg-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say system message');
expect(result).toBeDefined();
});
});
describe('Multiple UserPromptSubmit Hooks', () => {
it('should block when one of multiple parallel hooks returns block', async () => {
const allowScript =
'echo \'{"decision": "allow", "reason": "Allowed"}\'';
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked by security policy"}\'';
await rig.setup('ups-multi-one-blocks', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'ups-allow-hook',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'ups-block-hook',
timeout: 5000,
},
],
},
],
},
},
});
// When any hook blocks, CLI should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should block when first sequential hook returns block', async () => {
// Note: Sequential hooks execute ALL hooks before aggregating results.
// Even if the first hook returns block, the second hook still runs.
// The final aggregated result will be block if any hook returns block.
// For UserPromptSubmit, a block decision should cause CLI to throw an error.
const blockScript =
'echo \'{"decision": "block", "reason": "First hook blocks"}\'';
await rig.setup('ups-seq-first-blocks', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
sequential: true,
hooks: [
{
type: 'command',
command: blockScript,
name: 'ups-seq-block-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Single sequential hook with block decision should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should block when second sequential hook returns block', async () => {
// Note: Sequential hooks execute ALL hooks before aggregating results.
// The first hook allows, but the second hook blocks.
// The final aggregated result will be block (OR logic: any block = block).
const allowScript =
'echo \'{"decision": "allow", "reason": "First allows"}\'';
const blockScript =
'echo \'{"decision": "block", "reason": "Second hook blocks"}\'';
await rig.setup('ups-seq-second-blocks', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
sequential: true,
hooks: [
{
type: 'command',
command: allowScript,
name: 'ups-seq-first-allow',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'ups-seq-second-block',
timeout: 5000,
},
],
},
],
},
},
});
// Second hook blocks, CLI should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should handle multiple hooks all returning allow', async () => {
const allow1Script =
'echo \'{"decision": "allow", "reason": "First allows"}\'';
const allow2Script =
'echo \'{"decision": "allow", "reason": "Second allows"}\'';
const allow3Script =
'echo \'{"decision": "allow", "reason": "Third allows"}\'';
await rig.setup('ups-multi-all-allow', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: allow1Script,
name: 'ups-allow-1',
timeout: 5000,
},
{
type: 'command',
command: allow2Script,
name: 'ups-allow-2',
timeout: 5000,
},
{
type: 'command',
command: allow3Script,
name: 'ups-allow-3',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello');
// All hooks allow, should complete normally
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should handle multiple hooks all returning block', async () => {
const block1Script =
'echo \'{"decision": "block", "reason": "First blocks"}\'';
const block2Script =
'echo \'{"decision": "block", "reason": "Second blocks"}\'';
await rig.setup('ups-multi-all-block', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: block1Script,
name: 'ups-block-1',
timeout: 5000,
},
{
type: 'command',
command: block2Script,
name: 'ups-block-2',
timeout: 5000,
},
],
},
],
},
},
});
// All hooks block, CLI should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should concatenate additional context from multiple hooks', async () => {
const context1Script =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "context from hook 1"}}\'';
const context2Script =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "context from hook 2"}}\'';
await rig.setup('ups-multi-context', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: context1Script,
name: 'ups-context-1',
timeout: 5000,
},
{
type: 'command',
command: context2Script,
name: 'ups-context-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
});
it('should handle hook with error alongside blocking hook', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked"}\'';
await rig.setup('ups-error-with-block', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: '/nonexistent/command',
name: 'ups-error-hook',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'ups-block-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Block should still work despite error in other hook, CLI should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should handle hook timeout alongside blocking hook', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked while other times out"}\'';
await rig.setup('ups-timeout-with-block', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: 'sleep 60',
name: 'ups-timeout-hook',
timeout: 1000,
},
{
type: 'command',
command: blockScript,
name: 'ups-block-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Block should work despite timeout in other hook, CLI should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should handle multiple hook groups with different configurations', async () => {
const allow1Script =
'echo \'{"decision": "allow", "reason": "Group 1 allows"}\'';
const allow2Script =
'echo \'{"decision": "allow", "reason": "Group 2 allows"}\'';
await rig.setup('ups-multi-groups', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: allow1Script,
name: 'ups-group1-hook',
timeout: 5000,
},
],
},
{
sequential: true,
hooks: [
{
type: 'command',
command: allow2Script,
name: 'ups-group2-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
});
it('should block when one group blocks in multiple hook groups', async () => {
const allowScript =
'echo \'{"decision": "allow", "reason": "Group 1 allows"}\'';
const blockScript =
'echo \'{"decision": "block", "reason": "Group 2 blocks"}\'';
await rig.setup('ups-multi-groups-one-blocks', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'ups-group1-allow',
timeout: 5000,
},
],
},
{
hooks: [
{
type: 'command',
command: blockScript,
name: 'ups-group2-block',
timeout: 5000,
},
],
},
],
},
},
});
// One group blocks, CLI should throw an error
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
it('should handle modified prompt from multiple hooks', async () => {
const modify1Script =
'echo \'{"decision": "allow", "hookSpecificOutput": {"modifiedPrompt": "Modified by hook 1"}}\'';
const modify2Script =
'echo \'{"decision": "allow", "hookSpecificOutput": {"modifiedPrompt": "Modified by hook 2"}}\'';
await rig.setup('ups-multi-modify', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
sequential: true,
hooks: [
{
type: 'command',
command: modify1Script,
name: 'ups-modify-1',
timeout: 5000,
},
{
type: 'command',
command: modify2Script,
name: 'ups-modify-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
});
it('should handle system messages from multiple hooks', async () => {
const msg1Script =
'echo \'{"decision": "allow", "systemMessage": "System message 1"}\'';
const msg2Script =
'echo \'{"decision": "allow", "systemMessage": "System message 2"}\'';
await rig.setup('ups-multi-system-msg', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: msg1Script,
name: 'ups-msg-1',
timeout: 5000,
},
{
type: 'command',
command: msg2Script,
name: 'ups-msg-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
});
});
});
// ==========================================================================
// Stop Hooks
// Triggered when the agent is about to stop execution
// ==========================================================================
describe('Stop Hooks', () => {
describe('Allow Decision', () => {
it('should allow stopping when hook returns allow decision', async () => {
const allowStopScript =
'echo \'{"decision": "allow", "reason": "Stop allowed"}\'';
await rig.setup('stop-allow', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: allowStopScript,
name: 'stop-allow-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say stop test');
expect(result).toBeDefined();
});
it('should allow stopping and verify final response is produced', async () => {
const allowFinalScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Final context from stop hook"}}\'';
await rig.setup('stop-allow-final', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: allowFinalScript,
name: 'stop-final-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say goodbye');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
describe('Block Decision', () => {
it('should continue execution when hook returns block decision', async () => {
// Stop hook's block decision means "block stopping" (i.e., force continuation)
// not "block operation and show error"
// Use background process to write count file, ensuring final output is pure JSON
const blockStopScript =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Stop blocked by security policy"}\'';
await rig.setup('stop-block-decision', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: blockStopScript,
name: 'stop-block-hook',
timeout: 5000,
},
],
},
],
},
},
});
// When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop)
const result = await rig.run('Say hello', '--max-session-turns', '3');
// Verify that execution completed successfully (not blocked by Stop hook)
// Verify Stop hook was invoked multiple times (indicating multiple rounds)
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter((line) => line.trim() === 'hook_called').length;
expect(hookInvokeCount).toBeGreaterThan(1);
const toolLogs = rig.readToolLogs();
const hasActivity = result.length > 0 || toolLogs.length > 0;
expect(hasActivity).toBe(true);
});
it('should continue execution with custom reason', async () => {
// Stop hook's block decision means "block stopping" (i.e., force continuation)
const blockReasonScript =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Custom block reason: task incomplete"}\'';
await rig.setup('stop-block-custom-reason', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: blockReasonScript,
name: 'stop-block-reason-hook',
timeout: 5000,
},
],
},
],
},
},
});
// When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop)
const result = await rig.run('Say goodbye', '--max-session-turns', '3');
// Verify that execution completed successfully (not blocked by Stop hook)
// This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter((line) => line.trim() === 'hook_called').length;
expect(hookInvokeCount).toBeGreaterThan(1);
const toolLogs = rig.readToolLogs();
const hasActivity = result.length > 0 || toolLogs.length > 0;
expect(hasActivity).toBe(true);
});
});
describe('Additional Context', () => {
it('should include additional context in final response', async () => {
const contextScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Final context from hook"}}\'';
await rig.setup('stop-add-context', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: contextScript,
name: 'stop-context-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('What is 3+3?');
expect(result).toBeDefined();
});
it('should concatenate multiple additionalContext from multiple hooks', async () => {
const context1Script =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "context1"}}\'';
const context2Script =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "context2"}}\'';
await rig.setup('stop-multi-context', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: context1Script,
name: 'stop-context-1',
timeout: 5000,
},
{
type: 'command',
command: context2Script,
name: 'stop-context-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say multi context');
expect(result).toBeDefined();
});
});
describe('Timeout Handling', () => {
it('should continue stopping when hook times out', async () => {
await rig.setup('stop-timeout', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: 'sleep 60',
name: 'stop-timeout-hook',
timeout: 1000,
},
],
},
],
},
},
});
const result = await rig.run('Say timeout');
// Timeout should not prevent stopping
expect(result).toBeDefined();
});
});
describe('Error Handling', () => {
it('should continue stopping when hook has non-blocking error', async () => {
await rig.setup('stop-error', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: 'echo warning && exit 1',
name: 'stop-error-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say error');
// Error should not prevent stopping
expect(result).toBeDefined();
});
it('should continue stopping when hook command does not exist', async () => {
await rig.setup('stop-missing-command', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: 'false',
name: 'stop-missing-hook',
timeout: 1000,
},
],
},
],
},
},
});
const result = await rig.run('Say missing');
// Missing command should not prevent stopping
expect(result).toBeDefined();
});
});
describe('System Message', () => {
it('should include system message in final response', async () => {
const systemMsgScript =
'echo \'{"decision": "allow", "systemMessage": "Final system message from stop hook"}\'';
await rig.setup('stop-system-message', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: systemMsgScript,
name: 'stop-system-msg-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say final');
expect(result).toBeDefined();
});
});
describe('Multiple Stop Hooks', () => {
it('should continue execution when one of multiple parallel stop hooks returns block', async () => {
// Stop hook's block decision means "block stopping" (i.e., force continuation)
const allowScript =
'echo \'{"decision": "allow", "reason": "Stop allowed"}\'';
// Write to a file to count hook invocations, then echo the decision
const blockScript =
'echo "hook_called" >> hook_invoke_count.txt; echo \'{"decision": "block", "reason": "Stop blocked by security policy"}\'';
await rig.setup('stop-multi-one-blocks', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'stop-allow-hook',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'stop-block-hook',
timeout: 5000,
},
],
},
],
},
},
});
// When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop)
const result = await rig.run(
'Say multi stop',
'--max-session-turns',
'3',
);
// Verify that execution completed successfully (not blocked by Stop hook)
// This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter((line) => line.trim() === 'hook_called').length;
expect(hookInvokeCount).toBeGreaterThan(1);
const toolLogs = rig.readToolLogs();
const hasActivity = result.length > 0 || toolLogs.length > 0;
expect(hasActivity).toBe(true);
});
it('should continue execution when first sequential stop hook returns block', async () => {
// Stop hook's block decision means "block stopping" (i.e., force continuation)
const blockScript =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "First hook blocks stop"}\'';
const allowScript =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "allow", "reason": "This should still run"}\'';
await rig.setup('stop-seq-first-blocks', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
sequential: true,
hooks: [
{
type: 'command',
command: blockScript,
name: 'stop-seq-block-hook',
timeout: 5000,
},
{
type: 'command',
command: allowScript,
name: 'stop-seq-allow-hook',
timeout: 5000,
},
],
},
],
},
},
});
// When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop)
const result = await rig.run(
'Say sequential stop',
'--max-session-turns',
'3',
);
// Verify that execution completed successfully (not blocked by Stop hook)
// This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter((line) => line.trim() === 'hook_called').length;
expect(hookInvokeCount).toBeGreaterThan(1);
const toolLogs = rig.readToolLogs();
const hasActivity = result.length > 0 || toolLogs.length > 0;
expect(hasActivity).toBe(true);
});
it('should continue execution when second sequential stop hook returns block', async () => {
// Stop hook's block decision means "block stopping" (i.e., force continuation)
const allowScript =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "allow", "reason": "First allows"}\'';
const blockScript =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Second hook blocks stop"}\'';
await rig.setup('stop-seq-second-blocks', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
sequential: true,
hooks: [
{
type: 'command',
command: allowScript,
name: 'stop-seq-first-allow',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'stop-seq-second-block',
timeout: 5000,
},
],
},
],
},
},
});
// When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop)
const result = await rig.run(
'Say seq second blocks',
'--max-session-turns',
'3',
);
// Verify that execution completed successfully (not blocked by Stop hook)
// This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter((line) => line.trim() === 'hook_called').length;
expect(hookInvokeCount).toBeGreaterThan(1);
const toolLogs = rig.readToolLogs();
const hasActivity = result.length > 0 || toolLogs.length > 0;
expect(hasActivity).toBe(true);
});
it('should handle multiple stop hooks all returning allow', async () => {
const allow1Script =
'echo \'{"decision": "allow", "reason": "First allows"}\'';
const allow2Script =
'echo \'{"decision": "allow", "reason": "Second allows"}\'';
const allow3Script =
'echo \'{"decision": "allow", "reason": "Third allows"}\'';
await rig.setup('stop-multi-all-allow', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: allow1Script,
name: 'stop-allow-1',
timeout: 5000,
},
{
type: 'command',
command: allow2Script,
name: 'stop-allow-2',
timeout: 5000,
},
{
type: 'command',
command: allow3Script,
name: 'stop-allow-3',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say all allow');
// All hooks allow, should complete normally
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should handle multiple stop hooks all returning block', async () => {
const block1Script =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "First blocks"}\'';
const block2Script =
'(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Second blocks"}\'';
await rig.setup('stop-multi-all-block', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: block1Script,
name: 'stop-block-1',
timeout: 5000,
},
{
type: 'command',
command: block2Script,
name: 'stop-block-2',
timeout: 5000,
},
],
},
],
},
},
});
// When Stop hooks block, agent continues execution normally (with max turns to prevent infinite loop)
const _result = await rig.run(
'Say all block',
'--max-session-turns',
'3',
);
// Verify Stop hook was invoked multiple times (indicating multiple rounds)
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter((line) => line.trim() === 'hook_called').length;
expect(hookInvokeCount).toBeGreaterThan(1);
});
});
});
// ==========================================================================
// Multiple Hooks
// Tests for hook execution modes: sequential vs parallel
// ==========================================================================
describe('Multiple Hooks', () => {
describe('Sequential Execution', () => {
it('should execute hooks sequentially when sequential: true', async () => {
const hook1Script =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "first"}}\'';
const hook2Script =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "second"}}\'';
await rig.setup('multi-sequential', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
sequential: true,
hooks: [
{
type: 'command',
command: hook1Script,
name: 'seq-hook-1',
timeout: 5000,
},
{
type: 'command',
command: hook2Script,
name: 'seq-hook-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say sequential');
expect(result).toBeDefined();
});
it('should stop at first blocking hook and not execute subsequent', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked by first hook"}\'';
const allowScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('multi-first-blocks', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
sequential: true,
hooks: [
{
type: 'command',
command: blockScript,
name: 'seq-block-hook',
timeout: 5000,
},
{
type: 'command',
command: allowScript,
name: 'seq-should-not-run',
timeout: 5000,
},
],
},
],
},
},
});
// When the first hook blocks, the UserPromptSubmit should be blocked
await expect(rig.run('Create a file')).rejects.toThrow(
/blocked|Blocked by first hook/i,
);
});
it('should pass output from first hook to second hook input', async () => {
const passScript1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "from first", "passthrough": "data"}}\'';
const passScript2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "received passthrough"}}\'';
await rig.setup('multi-passthrough', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
sequential: true,
hooks: [
{
type: 'command',
command: passScript1,
name: 'passthrough-hook-1',
timeout: 5000,
},
{
type: 'command',
command: passScript2,
name: 'passthrough-hook-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say passthrough');
expect(result).toBeDefined();
});
});
describe('Parallel Execution', () => {
it('should execute hooks in parallel when sequential is not set', async () => {
const hook1Script = 'echo \'{"decision": "allow"}\'';
const hook2Script = 'echo \'{"decision": "allow"}\'';
await rig.setup('multi-parallel', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: hook1Script,
name: 'parallel-hook-1',
timeout: 5000,
},
{
type: 'command',
command: hook2Script,
name: 'parallel-hook-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say parallel');
expect(result).toBeDefined();
});
it('should handle mixed success/failure results from parallel hooks', async () => {
// For UserPromptSubmit hooks, command execution failure is treated as a blocking error
// So when one hook fails, the entire operation is blocked
const allowScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('multi-mixed', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'mixed-allow-hook',
timeout: 5000,
},
{
type: 'command',
command: '/nonexistent/command',
name: 'mixed-error-hook',
timeout: 5000,
},
],
},
],
},
},
});
// UserPromptSubmit hook command failure blocks the operation
await expect(rig.run('Say mixed')).rejects.toThrow(
/blocked|error|nonexistent/i,
);
});
it('should allow when any hook returns allow in parallel (OR logic)', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "blocked"}\'';
const allowScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('multi-or-logic', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: blockScript,
name: 'block-hook',
timeout: 5000,
},
{
type: 'command',
command: allowScript,
name: 'allow-hook',
timeout: 5000,
},
],
},
],
},
},
});
// With security-sensitive OR logic, block should win (most restrictive decision wins)
await expect(rig.run('Say or logic')).rejects.toThrow(/blocked|error/i);
});
});
});
// ==========================================================================
// SessionStart Hooks
// Tests for session start lifecycle hooks with rich matcher and aggregator scenarios
// ==========================================================================
describe('SessionStart Hooks', () => {
describe('Single SessionStart Hook', () => {
it('should execute SessionStart hook on session startup', async () => {
const sessionStartScript =
'echo \'{decision: "allow", hookSpecificOutput: {additionalContext: "Session started successfully"}}\'';
await rig.setup('session-start-basic', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: sessionStartScript,
name: 'session-start-basic-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should inject additional context from SessionStart hook', async () => {
const contextScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Project context: TypeScript React app with strict linting rules"}}\'';
await rig.setup('session-start-context', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: contextScript,
name: 'session-start-context-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('What project context do you have?');
expect(result).toBeDefined();
expect(result.toLowerCase()).toContain('typescript');
});
it('should handle SessionStart hook with system message', async () => {
const systemMsgScript =
'echo \'{"decision": "allow", "systemMessage": "Welcome! Session initialized with custom settings"}\'';
await rig.setup('session-start-system-msg', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: systemMsgScript,
name: 'session-start-system-msg-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
});
});
describe('SessionStart Matcher Scenarios', () => {
it('should match startup source with matcher', async () => {
const startupScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Startup hook executed"}}\'';
const otherScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Other hook executed"}}\'';
await rig.setup('session-start-matcher-startup', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup',
hooks: [
{
type: 'command',
command: startupScript,
name: 'session-start-startup-hook',
timeout: 5000,
},
],
},
{
matcher: 'resume',
hooks: [
{
type: 'command',
command: otherScript,
name: 'session-start-resume-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say startup test');
expect(result).toBeDefined();
});
it('should match multiple sources with regex matcher', async () => {
const multiSourceScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Multi-source hook executed"}}\'';
await rig.setup('session-start-matcher-regex', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup|resume',
hooks: [
{
type: 'command',
command: multiSourceScript,
name: 'session-start-multi-source-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say regex matcher test');
expect(result).toBeDefined();
});
it('should match all sources with wildcard matcher', async () => {
const wildcardScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Wildcard hook executed"}}\'';
await rig.setup('session-start-matcher-wildcard', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: '*',
hooks: [
{
type: 'command',
command: wildcardScript,
name: 'session-start-wildcard-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say wildcard test');
expect(result).toBeDefined();
});
it('should not execute when matcher does not match', async () => {
const noMatchScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Should not execute"}}\'';
await rig.setup('session-start-matcher-no-match', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'clear', // This won't match startup
hooks: [
{
type: 'command',
command: noMatchScript,
name: 'session-start-clear-only-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say no match test');
expect(result).toBeDefined();
});
it('should match clear source with matcher', async () => {
const clearScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Clear hook executed"}}\'';
await rig.setup('session-start-matcher-clear', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'clear',
hooks: [
{
type: 'command',
command: clearScript,
name: 'session-start-clear-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say clear test');
expect(result).toBeDefined();
});
it('should match compact source with matcher', async () => {
const compactScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Compact hook executed"}}\'';
await rig.setup('session-start-matcher-compact', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'compact',
hooks: [
{
type: 'command',
command: compactScript,
name: 'session-start-compact-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say compact test');
expect(result).toBeDefined();
});
it('should match all four sources with regex matcher', async () => {
const allSourcesScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "All sources hook executed"}}\'';
await rig.setup('session-start-matcher-all-sources', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup|resume|clear|compact',
hooks: [
{
type: 'command',
command: allSourcesScript,
name: 'session-start-all-sources-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say all sources test');
expect(result).toBeDefined();
});
it('should match startup and resume but not clear or compact', async () => {
const startupResumeScript =
'echo \'{decision: "allow", hookSpecificOutput: {additionalContext: "Startup/Resume hook executed"}}\'';
const clearCompactScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Clear/Compact hook executed"}}\'';
await rig.setup('session-start-matcher-partial', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup|resume',
hooks: [
{
type: 'command',
command: startupResumeScript,
name: 'session-start-startup-resume-hook',
timeout: 5000,
},
],
},
{
matcher: 'clear|compact',
hooks: [
{
type: 'command',
command: clearCompactScript,
name: 'session-start-clear-compact-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say partial matcher test');
expect(result).toBeDefined();
});
it('should handle invalid regex in matcher gracefully', async () => {
const invalidRegexScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Fallback to exact match"}}\'';
await rig.setup('session-start-matcher-invalid-regex', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: '[invalid-regex', // Invalid regex pattern
hooks: [
{
type: 'command',
command: invalidRegexScript,
name: 'session-start-invalid-regex-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say invalid regex test');
expect(result).toBeDefined();
});
it('should match all session start sources with individual hooks', async () => {
const startupScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Startup triggered"}}\'';
const resumeScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Resume triggered"}}\'';
const clearScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Clear triggered"}}\'';
const compactScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Compact triggered"}}\'';
await rig.setup('session-start-all-sources-individual', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup',
hooks: [
{
type: 'command',
command: startupScript,
name: 'session-start-startup-hook',
timeout: 5000,
},
],
},
{
matcher: 'resume',
hooks: [
{
type: 'command',
command: resumeScript,
name: 'session-start-resume-hook',
timeout: 5000,
},
],
},
{
matcher: 'clear',
hooks: [
{
type: 'command',
command: clearScript,
name: 'session-start-clear-hook',
timeout: 5000,
},
],
},
{
matcher: 'compact',
hooks: [
{
type: 'command',
command: compactScript,
name: 'session-start-compact-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say all sources individual test');
expect(result).toBeDefined();
});
});
describe('Multiple SessionStart Hooks', () => {
it('should execute multiple parallel SessionStart hooks', async () => {
const script1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Parallel hook 1"}}\'';
const script2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Parallel hook 2"}}\'';
const script3 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Parallel hook 3"}}\'';
await rig.setup('session-start-multi-parallel', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: script1,
name: 'session-start-parallel-1',
timeout: 5000,
},
{
type: 'command',
command: script2,
name: 'session-start-parallel-2',
timeout: 5000,
},
{
type: 'command',
command: script3,
name: 'session-start-parallel-3',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say multi parallel');
expect(result).toBeDefined();
});
it('should execute sequential SessionStart hooks in order', async () => {
const script1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Sequential hook 1"}}\'';
const script2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Sequential hook 2"}}\'';
await rig.setup('session-start-multi-sequential', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
sequential: true,
hooks: [
{
type: 'command',
command: script1,
name: 'session-start-seq-1',
timeout: 5000,
},
{
type: 'command',
command: script2,
name: 'session-start-seq-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say sequential');
expect(result).toBeDefined();
});
it('should concatenate additional context from multiple hooks', async () => {
const context1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Context from hook 1"}}\'';
const context2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Context from hook 2"}}\'';
await rig.setup('session-start-multi-context', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: context1,
name: 'session-start-ctx-1',
timeout: 5000,
},
{
type: 'command',
command: context2,
name: 'session-start-ctx-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('What context do you have?');
expect(result).toBeDefined();
});
it('should handle system messages from multiple hooks', async () => {
const msg1 =
'echo \'{"decision": "allow", "systemMessage": "System message 1"}\'';
const msg2 =
'echo \'{"decision": "allow", "systemMessage": "System message 2"}\'';
await rig.setup('session-start-multi-system-msg', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: msg1,
name: 'session-start-sys-1',
timeout: 5000,
},
{
type: 'command',
command: msg2,
name: 'session-start-sys-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
});
});
describe('SessionStart Error Handling', () => {
it('should continue session when hook exits with non-blocking error', async () => {
await rig.setup('session-start-nonblocking-error', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: 'echo warning && exit 1',
name: 'session-start-error-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say error test');
expect(result).toBeDefined();
});
it('should continue session when hook command does not exist', async () => {
await rig.setup('session-start-missing-command', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: '/nonexistent/session/start/command',
name: 'session-start-missing-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say missing test');
expect(result).toBeDefined();
});
it('should handle hook timeout gracefully', async () => {
await rig.setup('session-start-timeout', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
hooks: [
{
type: 'command',
command: 'sleep 60',
name: 'session-start-timeout-hook',
timeout: 1000, // 1 second timeout
},
],
},
],
},
},
});
const result = await rig.run('Say timeout test');
expect(result).toBeDefined();
});
});
});
// ==========================================================================
// SessionEnd Hooks
// Tests for session end lifecycle hooks with various exit reasons
// ==========================================================================
describe('SessionEnd Hooks', () => {
describe('Single SessionEnd Hook', () => {
it('should execute SessionEnd hook on session end', async () => {
const sessionEndScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('session-end-basic', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: sessionEndScript,
name: 'session-end-basic-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
});
it('should execute SessionEnd hook with cleanup tasks', async () => {
const cleanupScript =
'echo {decision: "allow", hookSpecificOutput: {additionalContext: "Cleanup completed"}}';
await rig.setup('session-end-cleanup', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: cleanupScript,
name: 'session-end-cleanup-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say cleanup test');
expect(result).toBeDefined();
});
});
describe('SessionEnd Matcher Scenarios', () => {
it('should match specific exit reason with matcher', async () => {
const clearScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Clear hook executed"}}\'';
const logoutScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Logout hook executed"}}\'';
await rig.setup('session-end-matcher-clear', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
matcher: 'clear',
hooks: [
{
type: 'command',
command: clearScript,
name: 'session-end-clear-hook',
timeout: 5000,
},
],
},
{
matcher: 'logout',
hooks: [
{
type: 'command',
command: logoutScript,
name: 'session-end-logout-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say matcher test');
expect(result).toBeDefined();
});
it('should match multiple exit reasons with regex matcher', async () => {
const multiReasonScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Multi-reason hook executed"}}\'';
await rig.setup('session-end-matcher-regex', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
matcher: 'clear|logout|other',
hooks: [
{
type: 'command',
command: multiReasonScript,
name: 'session-end-multi-reason-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say regex matcher test');
expect(result).toBeDefined();
});
it('should match all reasons with wildcard matcher', async () => {
const wildcardScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Wildcard end hook executed"}}\'';
await rig.setup('session-end-matcher-wildcard', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
matcher: '*',
hooks: [
{
type: 'command',
command: wildcardScript,
name: 'session-end-wildcard-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say wildcard test');
expect(result).toBeDefined();
});
it('should handle invalid regex in SessionEnd matcher gracefully', async () => {
const invalidRegexScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "SessionEnd fallback to exact match"}}\'';
await rig.setup('session-end-matcher-invalid-regex', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
matcher: '[invalid-regex', // Invalid regex pattern
hooks: [
{
type: 'command',
command: invalidRegexScript,
name: 'session-end-invalid-regex-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say invalid regex SessionEnd test');
expect(result).toBeDefined();
});
it('should match all SessionEnd reasons with individual hooks', async () => {
const clearScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Clear reason triggered"}}\'';
const logoutScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Logout reason triggered"}}\'';
const promptExitScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "PromptInputExit reason triggered"}}\'';
const bypassDisabledScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Bypass permissions disabled triggered"}}\'';
const otherScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Other reason triggered"}}\'';
await rig.setup('session-end-all-reasons-individual', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
matcher: 'clear',
hooks: [
{
type: 'command',
command: clearScript,
name: 'session-end-clear-hook',
timeout: 5000,
},
],
},
{
matcher: 'logout',
hooks: [
{
type: 'command',
command: logoutScript,
name: 'session-end-logout-hook',
timeout: 5000,
},
],
},
{
matcher: 'promptInputExit',
hooks: [
{
type: 'command',
command: promptExitScript,
name: 'session-end-prompt-exit-hook',
timeout: 5000,
},
],
},
{
matcher: 'bypass_permissions_disabled',
hooks: [
{
type: 'command',
command: bypassDisabledScript,
name: 'session-end-bypass-disabled-hook',
timeout: 5000,
},
],
},
{
matcher: 'other',
hooks: [
{
type: 'command',
command: otherScript,
name: 'session-end-other-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say all SessionEnd reasons test');
expect(result).toBeDefined();
});
});
describe('Multiple SessionEnd Hooks', () => {
it('should execute multiple parallel SessionEnd hooks', async () => {
const script1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "End hook 1"}}\'';
const script2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "End hook 2"}}\'';
await rig.setup('session-end-multi-parallel', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: script1,
name: 'session-end-parallel-1',
timeout: 5000,
},
{
type: 'command',
command: script2,
name: 'session-end-parallel-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say multi parallel end');
expect(result).toBeDefined();
});
it('should execute sequential SessionEnd hooks in order', async () => {
const script1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Sequential end hook 1"}}\'';
const script2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Sequential end hook 2"}}\'';
await rig.setup('session-end-multi-sequential', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
sequential: true,
hooks: [
{
type: 'command',
command: script1,
name: 'session-end-seq-1',
timeout: 5000,
},
{
type: 'command',
command: script2,
name: 'session-end-seq-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say sequential end');
expect(result).toBeDefined();
});
it('should concatenate additional context from multiple hooks', async () => {
const context1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "End context from hook 1"}}\'';
const context2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "End context from hook 2"}}\'';
await rig.setup('session-end-multi-context', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: context1,
name: 'session-end-ctx-1',
timeout: 5000,
},
{
type: 'command',
command: context2,
name: 'session-end-ctx-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say end context test');
expect(result).toBeDefined();
});
});
describe('SessionEnd Block Scenarios', () => {
it('should block session end when hook returns block decision', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "Session end blocked by policy"}\'';
await rig.setup('session-end-block', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: blockScript,
name: 'session-end-block-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say block test');
expect(result).toBeDefined();
// Session should not end, agent continues
expect(result.toLowerCase()).toContain('block');
});
it('should allow session end when hook returns allow decision', async () => {
const allowScript =
'echo \'{"decision": "allow", "reason": "Session end allowed"}\'';
await rig.setup('session-end-allow', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'session-end-allow-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say allow test');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should block when one of multiple parallel hooks returns block', async () => {
const allowScript =
'echo \'{"decision": "allow", "reason": "Allowed"}\'';
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked by security policy"}\'';
await rig.setup('session-end-multi-one-blocks', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'session-end-allow-hook',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'session-end-block-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say multi block test');
expect(result).toBeDefined();
expect(result.toLowerCase()).toContain('block');
});
it('should block when first sequential hook returns block', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "First hook blocks session end"}\'';
const allowScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('session-end-seq-first-blocks', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
sequential: true,
hooks: [
{
type: 'command',
command: blockScript,
name: 'session-end-seq-block-hook',
timeout: 5000,
},
{
type: 'command',
command: allowScript,
name: 'session-end-seq-allow-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say seq block test');
expect(result).toBeDefined();
expect(result.toLowerCase()).toContain('block');
});
it('should allow when all hooks return allow', async () => {
const allow1Script =
'echo \'{"decision": "allow", "reason": "First allows"}\'';
const allow2Script =
'echo \'{"decision": "allow", "reason": "Second allows"}\'';
await rig.setup('session-end-all-allow', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: allow1Script,
name: 'session-end-allow-1',
timeout: 5000,
},
{
type: 'command',
command: allow2Script,
name: 'session-end-allow-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say all allow test');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should handle block with reason in session end', async () => {
const blockWithReasonScript =
'echo \'{"decision": "block", "reason": "Critical operations pending - cannot end session"} \'';
await rig.setup('session-end-block-with-reason', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: blockWithReasonScript,
name: 'session-end-block-reason-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say block with reason');
expect(result).toBeDefined();
expect(result.toLowerCase()).toContain('block');
});
});
describe('SessionEnd Error Handling', () => {
it('should continue session end when hook exits with non-blocking error', async () => {
await rig.setup('session-end-nonblocking-error', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: 'echo warning && exit 1',
name: 'session-end-error-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say error test');
expect(result).toBeDefined();
});
it('should continue session end when hook command does not exist', async () => {
await rig.setup('session-end-missing-command', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: '/nonexistent/session/end/command',
name: 'session-end-missing-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say missing test');
expect(result).toBeDefined();
});
});
describe('Multiple SessionEnd Hooks', () => {
it('should block when one of multiple parallel hooks returns block', async () => {
const allowScript = 'echo \'{"decision": "allow"}\'';
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked"}\'';
await rig.setup('session-end-multi-one-blocks', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'session-end-allow-hook',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'session-end-block-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
// SessionEnd hooks run after the main command completes and don't affect the main output
expect(result.toLowerCase()).not.toContain('block');
});
it('should block when first sequential hook returns block', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked"}\'';
const allowScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('session-end-seq-first-blocks', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
sequential: true,
hooks: [
{
type: 'command',
command: blockScript,
name: 'session-end-seq-block-hook',
timeout: 5000,
},
{
type: 'command',
command: allowScript,
name: 'session-end-seq-allow-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say test');
expect(result).toBeDefined();
// SessionEnd hooks run after the main command completes and don't affect the main output
expect(result.toLowerCase()).not.toContain('block');
});
it('should handle multiple hooks all returning allow', async () => {
const allow1Script =
'echo \'{"decision": "allow", "reason": "First allows"}\'';
const allow2Script =
'echo \'{"decision": "allow", "reason": "Second allows"}\'';
await rig.setup('session-end-multi-all-allow', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: allow1Script,
name: 'session-end-allow-1',
timeout: 5000,
},
{
type: 'command',
command: allow2Script,
name: 'session-end-allow-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should concatenate additional context from multiple hooks', async () => {
const context1Script =
'echo {decision: "allow", hookSpecificOutput: {additionalContext: "context from session end hook 1"}}';
const context2Script =
'echo {decision: "allow", hookSpecificOutput: {additionalContext: "context from session end hook 2"}}';
await rig.setup('session-end-multi-context', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: context1Script,
name: 'session-end-context-1',
timeout: 5000,
},
{
type: 'command',
command: context2Script,
name: 'session-end-context-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
});
it('should handle hook with error alongside blocking hook', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked"}\'';
await rig.setup('session-end-error-with-block', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: '/nonexistent/command',
name: 'session-end-error-hook',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'session-end-block-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say test');
expect(result).toBeDefined();
// SessionEnd hooks run after the main command completes and don't affect the main output
expect(result.toLowerCase()).not.toContain('block');
});
it('should handle hook timeout alongside blocking hook', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked"}\'';
await rig.setup('session-end-timeout-with-block', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: 'sleep 60',
name: 'session-end-timeout-hook',
timeout: 1000,
},
{
type: 'command',
command: blockScript,
name: 'session-end-block-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say test');
expect(result).toBeDefined();
// SessionEnd hooks run after the main command completes and don't affect the main output
expect(result.toLowerCase()).not.toContain('block');
});
it('should handle system messages from multiple hooks', async () => {
const msg1Script =
'echo \'{"decision": "allow", "systemMessage": "System message 1 from SessionEnd"}\'';
const msg2Script =
'echo \'{"decision": "allow", "systemMessage": "System message 2 from SessionEnd"}\'';
await rig.setup('session-end-multi-system-msg', {
settings: {
hooks: {
enabled: true,
SessionEnd: [
{
hooks: [
{
type: 'command',
command: msg1Script,
name: 'session-end-msg-1',
timeout: 5000,
},
{
type: 'command',
command: msg2Script,
name: 'session-end-msg-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello');
expect(result).toBeDefined();
});
});
});
// ==========================================================================
// Combined Hooks
// Tests for using multiple hook types together
// ==========================================================================
// Combined Hooks
// Tests for using multiple hook types together
// ==========================================================================
describe('Combined Hooks', () => {
it('should execute both Stop and UserPromptSubmit hooks in same session', async () => {
const stopScript = 'echo \'{"decision": "allow"}\'';
const upsScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('combined-both-hooks', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: stopScript,
name: 'stop-hook',
timeout: 5000,
},
],
},
],
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: upsScript,
name: 'ups-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say both hooks');
expect(result).toBeDefined();
});
it('should execute multiple hook types together', async () => {
const upsScript = 'echo \'{"decision": "allow"}\'';
const sessionEndScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('combined-ups-sessionend', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: upsScript,
name: 'ups-hook',
timeout: 5000,
},
],
},
],
SessionEnd: [
{
hooks: [
{
type: 'command',
command: sessionEndScript,
name: 'session-end-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say hello with multiple hooks');
expect(result).toBeDefined();
});
it('should execute Stop, UserPromptSubmit and SessionEnd hooks together', async () => {
const stopScript = 'echo \'{"decision": "allow"}\'';
const upsScript = 'echo \'{"decision": "allow"}\'';
const sessionEndScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('combined-three-hooks', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: stopScript,
name: 'stop-hook',
timeout: 5000,
},
],
},
],
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: upsScript,
name: 'ups-hook',
timeout: 5000,
},
],
},
],
SessionEnd: [
{
hooks: [
{
type: 'command',
command: sessionEndScript,
name: 'session-end-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say hello with three hooks');
expect(result).toBeDefined();
});
it('should execute all hook types together', async () => {
const stopScript = 'echo \'{"decision": "allow"}\'';
const upsScript = 'echo \'{"decision": "allow"}\'';
const sessionEndScript = 'echo \'{"decision": "allow"}\'';
const permissionScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('combined-all-hooks', {
settings: {
disableAllHooks: false,
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: stopScript,
name: 'stop-hook',
timeout: 5000,
},
],
},
],
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: upsScript,
name: 'ups-hook',
timeout: 5000,
},
],
},
],
SessionEnd: [
{
hooks: [
{
type: 'command',
command: sessionEndScript,
name: 'session-end-hook',
timeout: 5000,
},
],
},
],
PermissionRequest: [
{
hooks: [
{
type: 'command',
command: permissionScript,
name: 'permission-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say hello with all hooks');
expect(result).toBeDefined();
});
});
// ==========================================================================
// Hook Script File Tests
// Tests for executing hooks from external script files
// ==========================================================================
describe('Hook Script File Tests', () => {
it('should execute hook from script file', async () => {
const scriptFileHook =
'echo \'{"decision": "allow", "reason": "Approved by script file", "hookSpecificOutput": {"additionalContext": "Script file executed successfully"}}\'';
await rig.setup('script-file-hook', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: scriptFileHook,
name: 'script-file-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
const result = await rig.run('Say script file test');
expect(result).toBeDefined();
});
it('should execute blocking hook from script file', async () => {
const scriptBlockHook =
'echo \'{"decision": "block", "reason": "Blocked by security script"}\'';
await rig.setup('script-file-block-hook', {
settings: {
disableAllHooks: false,
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: scriptBlockHook,
name: 'script-block-hook',
timeout: 5000,
},
],
},
],
},
trusted: true,
},
});
// When UserPromptSubmit hook blocks, CLI exits with non-zero code
await expect(rig.run('Create a file')).rejects.toThrow(/block/i);
});
});
// ==========================================================================
// PermissionRequest Hooks
// Tests for permission request lifecycle hooks that control tool access
// ==========================================================================
describe('PermissionRequest Hooks', () => {
describe('Single PermissionRequest Hook - Allow Scenarios', () => {
it('should allow tool execution when hook returns allow decision', async () => {
const allowScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Tool access granted by permission hook"}}\'';
await rig.setup('permission-req-allow-basic', {
settings: {
disableAllHooks: false,
hooks: {
PermissionRequest: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'permission-req-allow-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run(
'Create a file test.txt with content "hello"',
);
expect(result).toBeDefined();
const fileContent = rig.readFile('test.txt');
expect(fileContent).toContain('hello');
});
it('should allow specific tools based on tool name matching', async () => {
const allowSafeToolsScript = `
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
if [ "$TOOL_NAME" = "Read" ] || [ "$TOOL_NAME" = "Grep" ]; then
echo '{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Safe tool access granted"}}'
else
echo '{}'
fi
`;
await rig.setup('permission-req-allow-safe-tools', {
settings: {
disableAllHooks: false,
hooks: {
PermissionRequest: [
{
matcher: 'Read|Grep',
hooks: [
{
type: 'command',
command: allowSafeToolsScript,
name: 'permission-req-allow-safe-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Test with a Read operation
const result = await rig.run('Read the package.json file');
expect(result).toBeDefined();
});
});
describe('Single PermissionRequest Hook - Deny Scenarios', () => {
it('should deny tool execution when hook returns deny decision', async () => {
const denyScript =
'echo \'{"decision": "deny", "reason": "Tool execution denied by security hook", "hookSpecificOutput": {"additionalContext": "Security policy violation"}}\'';
await rig.setup('permission-req-deny-basic', {
settings: {
disableAllHooks: false,
hooks: {
PermissionRequest: [
{
hooks: [
{
type: 'command',
command: denyScript,
name: 'permission-req-deny-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Note: Currently the PermissionRequest deny decision may not block tool execution
// This test verifies that the hook is executed and returns the expected decision
const result = await rig.run(
'Create a file denied.txt with content "should be blocked"',
);
expect(result).toBeDefined();
// The hook is triggered but current implementation may not block execution
// This highlights the gap where deny decisions don't prevent tool execution
// In future, we'd expect the deny decision to block execution and result to contain deny-related message
});
it('should block dangerous operations based on tool input matching', async () => {
const blockDangerousOpsScript = `
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [ "$TOOL_NAME" = "Bash" ] && [[ "$COMMAND" == *"rm -rf"* ]]; then
echo '{"decision": "deny", "reason": "Dangerous command blocked", "hookSpecificOutput": {"additionalContext": "Security threat detected"}}'
else
echo '{"decision": "allow"}'
fi
`;
await rig.setup('permission-req-block-dangerous', {
settings: {
disableAllHooks: false,
hooks: {
PermissionRequest: [
{
matcher: 'Bash',
hooks: [
{
type: 'command',
command: blockDangerousOpsScript,
name: 'permission-req-block-dangerous-hook',
timeout: 5000,
},
],
},
],
},
},
});
// This command should ideally be blocked by the hook
// Note: Currently the PermissionRequest deny decision may not block tool execution
const result = await rig.run('Execute bash command: rm -rf /tmp');
expect(result).toBeDefined();
// The hook system correctly identifies dangerous operations
// But current implementation may not fully enforce the deny decision
});
});
describe('Multiple PermissionRequest Hooks - Allow Scenarios', () => {
it('should allow tool execution when all hooks return allow decision', async () => {
const allowScript1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "First permission check passed"}}\'';
const allowScript2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Second permission check passed"}}\'';
await rig.setup('permission-req-multi-allow', {
settings: {
disableAllHooks: false,
hooks: {
PermissionRequest: [
{
hooks: [
{
type: 'command',
command: allowScript1,
name: 'permission-req-allow-1',
timeout: 5000,
},
{
type: 'command',
command: allowScript2,
name: 'permission-req-allow-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run(
'Create a file multi-test.txt with content "multi allow"',
);
expect(result).toBeDefined();
const fileContent = rig.readFile('multi-test.txt');
expect(fileContent).toContain('multi allow');
});
it('should allow execution with sequential permission checks', async () => {
const allowScript1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "First sequential check passed"}}\'';
const allowScript2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Second sequential check passed"}}\'';
await rig.setup('permission-req-sequential-allow', {
settings: {
disableAllHooks: false,
hooks: {
PermissionRequest: [
{
sequential: true,
hooks: [
{
type: 'command',
command: allowScript1,
name: 'permission-req-seq-allow-1',
timeout: 5000,
},
{
type: 'command',
command: allowScript2,
name: 'permission-req-seq-allow-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Read this test file');
expect(result).toBeDefined();
});
});
describe('Multiple PermissionRequest Hooks - Deny Scenarios', () => {
it('should deny tool execution when one hook returns deny decision in parallel', async () => {
const allowScript = 'echo \'{"decision": "allow"}\'';
const denyScript =
'echo \'{"decision": "deny", "reason": "Denied by security policy"}\'';
await rig.setup('permission-req-multi-one-denies', {
settings: {
disableAllHooks: false,
hooks: {
PermissionRequest: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'permission-req-allow-parallel',
timeout: 5000,
},
{
type: 'command',
command: denyScript,
name: 'permission-req-deny-parallel',
timeout: 5000,
},
],
},
],
},
},
});
// Note: Currently the PermissionRequest deny decision may not block tool execution
// In a proper implementation, one deny decision among parallel hooks should block execution
const result = await rig.run(
'Create a file blocked.txt with content "should not be created"',
);
expect(result).toBeDefined();
// This test demonstrates the current behavior where deny decisions may not block execution
// Future implementation should ensure that a deny decision blocks the tool execution
});
it('should deny execution when first sequential hook denies', async () => {
const denyScript =
'echo \'{"decision": "deny", "reason": "First check denied execution"}\'';
const allowScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('permission-req-sequential-first-denies', {
settings: {
disableAllHooks: false,
hooks: {
PermissionRequest: [
{
sequential: true,
hooks: [
{
type: 'command',
command: denyScript,
name: 'permission-req-seq-deny-first',
timeout: 5000,
},
{
type: 'command',
command: allowScript,
name: 'permission-req-seq-allow-second',
timeout: 5000,
},
],
},
],
},
},
});
// Note: Currently the PermissionRequest deny decision may not block tool execution
// In a proper implementation, the first deny decision should prevent subsequent hooks from executing
// and block the tool execution entirely
const result = await rig.run(
'Try to write a file that should be blocked',
);
expect(result).toBeDefined();
// This test highlights where the implementation could be strengthened
// to properly respect deny decisions in sequential hook execution
});
});
describe('PermissionRequest Matcher Scenarios', () => {
it('should match specific tools with regex matcher', async () => {
const specificToolScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Specific tool matched and allowed"}}\'';
await rig.setup('permission-req-matcher-specific', {
settings: {
disableAllHooks: false,
hooks: {
PermissionRequest: [
{
matcher: 'Read|Write',
hooks: [
{
type: 'command',
command: specificToolScript,
name: 'permission-req-specific-tool-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Read the current directory');
expect(result).toBeDefined();
});
it('should match all tools with wildcard matcher', async () => {
const wildcardScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Wildcard matcher allowed all tools"}}\'';
await rig.setup('permission-req-matcher-wildcard', {
settings: {
disableAllHooks: false,
hooks: {
PermissionRequest: [
{
matcher: '*',
hooks: [
{
type: 'command',
command: wildcardScript,
name: 'permission-req-wildcard-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say wildcard test');
expect(result).toBeDefined();
});
});
});
// ==========================================================================
// SubagentStart Hooks
// Triggered when a subagent is spawned via the Task tool
// ==========================================================================
describe('SubagentStart Hooks', () => {
describe('Single SubagentStart Hook', () => {
it('should execute SubagentStart hook when a subagent is launched', async () => {
const hookScript =
'echo \'{"hookSpecificOutput": {"additionalContext": "Subagent start approved"}}\'';
await rig.setup('subagent-start-basic', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStart: [
{
hooks: [
{
type: 'command',
command: hookScript,
name: 'subagent-start-basic-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Use the Task tool to trigger SubagentStart
const result = await rig.run(
'Use the Task tool to create a bash subagent that says "hello from subagent"',
);
expect(result).toBeDefined();
});
it('should inject additional context from SubagentStart hook', async () => {
const contextScript =
'echo \'{"hookSpecificOutput": {"additionalContext": "Security check passed for subagent"}}\'';
await rig.setup('subagent-start-context', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStart: [
{
hooks: [
{
type: 'command',
command: contextScript,
name: 'subagent-start-context-hook',
timeout: 5000,
},
],
},
],
},
},
});
// The additional context should be available to the subagent
const result = await rig.run(
'Use the Task tool to create a bash subagent that says "hello"',
);
expect(result).toBeDefined();
});
it('should execute SubagentStart hook with additional context', async () => {
const contextScript =
'echo \'{"hookSpecificOutput": {"additionalContext": "Audit log created"}}\'';
await rig.setup('subagent-start-context-only', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStart: [
{
hooks: [
{
type: 'command',
command: contextScript,
name: 'subagent-start-context-only-hook',
timeout: 5000,
},
],
},
],
},
},
});
// The hook should be called and subagent should execute normally
const result = await rig.run(
'Use the Task tool to create a bash subagent that says "hello"',
);
expect(result).toBeDefined();
});
it('should handle error when SubagentStart hook command fails', async () => {
const errorScript = 'echo "some error output" >&2; exit 1';
await rig.setup('subagent-start-error', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStart: [
{
hooks: [
{
type: 'command',
command: errorScript,
name: 'subagent-start-error-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Even with error hooks, the subagent should still run
const result = await rig.run(
'Use the Task tool to create a bash subagent that says "hello"',
);
expect(result).toBeDefined();
});
});
describe('Multiple SubagentStart Hooks', () => {
it('should execute multiple SubagentStart hooks in parallel', async () => {
const hook1Script =
'(echo "hook1_called" >> hook_invoke_count.txt &) ; echo \'{"hookSpecificOutput": {"additionalContext": "Hook1 executed"}}\'';
const hook2Script =
'(echo "hook2_called" >> hook_invoke_count.txt &) ; echo \'{"hookSpecificOutput": {"additionalContext": "Hook2 executed"}}\'';
await rig.setup('subagent-start-parallel', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStart: [
{
hooks: [
{
type: 'command',
command: hook1Script,
name: 'subagent-start-hook1',
timeout: 5000,
},
{
type: 'command',
command: hook2Script,
name: 'subagent-start-hook2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run(
'Use the Task tool to create a bash subagent that says "hello"',
);
expect(result).toBeDefined();
// Both hooks should have been invoked
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter(
(line) =>
line.trim() === 'hook1_called' || line.trim() === 'hook2_called',
).length;
expect(hookInvokeCount).toBeGreaterThanOrEqual(0);
});
it('should execute multiple SubagentStart hooks sequentially', async () => {
const hook1Script =
'(echo "hook1_called" >> hook_invoke_count.txt &) ; echo \'{"hookSpecificOutput": {"additionalContext": "Hook1 executed"}}\'';
const hook2Script =
'(echo "hook2_called" >> hook_invoke_count.txt &) ; echo \'{"hookSpecificOutput": {"additionalContext": "Hook2 executed"}}\'';
await rig.setup('subagent-start-sequential', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStart: [
{
sequential: true,
hooks: [
{
type: 'command',
command: hook1Script,
name: 'subagent-start-seq-hook1',
timeout: 5000,
},
{
type: 'command',
command: hook2Script,
name: 'subagent-start-seq-hook2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run(
'Use the Task tool to create a bash subagent that says "hello"',
);
expect(result).toBeDefined();
// Both hooks should have been invoked sequentially
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter(
(line) =>
line.trim() === 'hook1_called' || line.trim() === 'hook2_called',
).length;
expect(hookInvokeCount).toBeGreaterThanOrEqual(0);
});
});
describe('SubagentStart Matcher Scenarios', () => {
it('should match specific agent types with exact matcher', async () => {
const specificAgentScript =
'echo \'{"hookSpecificOutput": {"additionalContext": "Specific agent type matched"}}\'';
await rig.setup('subagent-start-matcher-specific', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStart: [
{
matcher: 'Bash',
hooks: [
{
type: 'command',
command: specificAgentScript,
name: 'subagent-start-specific-agent-hook',
timeout: 5000,
},
],
},
],
},
},
});
// This should trigger the hook since we're launching a bash subagent
const result = await rig.run(
'Use the Task tool to create a bash subagent that says "hello"',
);
expect(result).toBeDefined();
});
it('should match all agent types with wildcard matcher', async () => {
const wildcardScript =
'echo \'{"hookSpecificOutput": {"additionalContext": "Wildcard matcher matched all agent types"}}\'';
await rig.setup('subagent-start-matcher-wildcard', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStart: [
{
matcher: '*',
hooks: [
{
type: 'command',
command: wildcardScript,
name: 'subagent-start-wildcard-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run(
'Use the Task tool to create a bash subagent that says "hello"',
);
expect(result).toBeDefined();
});
});
});
// ==========================================================================
// SubagentStop Hooks
// Triggered when a subagent finishes responding
// ==========================================================================
describe('SubagentStop Hooks', () => {
describe('Single SubagentStop Hook', () => {
it('should execute SubagentStop hook when a subagent finishes', async () => {
const hookScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Subagent stop processed"}}\'';
await rig.setup('subagent-stop-basic', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStop: [
{
hooks: [
{
type: 'command',
command: hookScript,
name: 'subagent-stop-basic-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Use the Task tool to trigger both SubagentStart and SubagentStop
const result = await rig.run(
'Use the Task tool to create a bash subagent that says "hello from subagent"',
);
expect(result).toBeDefined();
});
it('should allow subagent to continue when SubagentStop hook blocks and requires continuation', async () => {
// Create a script that returns block only once, then allow
const blockOnceScript =
'if [ -f hook_stop_state.txt ]; then echo \'{"decision": "allow"}\'; else echo "blocked_once" > hook_stop_state.txt; echo \'{"decision": "block", "reason": "File writing blocked by security policy, retrying..."}\'; fi';
await rig.setup('subagent-stop-block-once', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStop: [
{
hooks: [
{
type: 'command',
command: blockOnceScript,
name: 'subagent-stop-block-once-hook',
timeout: 5000,
},
],
},
],
},
},
});
// When SubagentStop hook blocks once, the subagent should receive the feedback and continue
const result = await rig.run(
'Use the Task tool to create a bash subagent to write a test file with "hello"',
);
expect(result).toBeDefined();
// Verify that the state file was created with expected content (indicating block was triggered once)
const stateContent = rig.readFile('hook_stop_state.txt');
expect(stateContent).toContain('blocked_once');
});
it('should handle error when SubagentStop hook command fails', async () => {
const errorScript = 'echo "some error output" >&2; exit 1';
await rig.setup('subagent-stop-error', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStop: [
{
hooks: [
{
type: 'command',
command: errorScript,
name: 'subagent-stop-error-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Even with error hooks, the subagent should still complete
const result = await rig.run(
'Use the Task tool to create a bash subagent that says "hello"',
);
expect(result).toBeDefined();
});
});
describe('Multiple SubagentStop Hooks', () => {
it('should execute multiple SubagentStop hooks in parallel', async () => {
const hook1Script =
'(echo "hook1_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "allow"}\'';
const hook2Script =
'(echo "hook2_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "allow"}\'';
await rig.setup('subagent-stop-parallel', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStop: [
{
hooks: [
{
type: 'command',
command: hook1Script,
name: 'subagent-stop-hook1',
timeout: 5000,
},
{
type: 'command',
command: hook2Script,
name: 'subagent-stop-hook2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run(
'Use the Task tool to create a bash subagent that says "hello"',
);
expect(result).toBeDefined();
// Both hooks should have been invoked
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter(
(line) =>
line.trim() === 'hook1_called' || line.trim() === 'hook2_called',
).length;
expect(hookInvokeCount).toBeGreaterThanOrEqual(2);
});
it('should execute multiple SubagentStop hooks sequentially', async () => {
const hook1Script =
'(echo "hook1_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "allow"}\'';
const hook2Script =
'(echo "hook2_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "allow"}\'';
await rig.setup('subagent-stop-sequential', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStop: [
{
sequential: true,
hooks: [
{
type: 'command',
command: hook1Script,
name: 'subagent-stop-seq-hook1',
timeout: 5000,
},
{
type: 'command',
command: hook2Script,
name: 'subagent-stop-seq-hook2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run(
'Use the Task tool to create a bash subagent that says "hello"',
);
expect(result).toBeDefined();
// Both hooks should have been invoked sequentially
const hookInvokeCount = rig
.readFile('hook_invoke_count.txt')
.split('\n')
.filter(
(line) =>
line.trim() === 'hook1_called' || line.trim() === 'hook2_called',
).length;
expect(hookInvokeCount).toBeGreaterThanOrEqual(2);
});
});
describe('SubagentStop Matcher Scenarios', () => {
it('should match specific agent types with exact matcher', async () => {
const specificAgentScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Specific agent type matched and allowed at stop"}}\'';
await rig.setup('subagent-stop-matcher-specific', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStop: [
{
matcher: 'Bash',
hooks: [
{
type: 'command',
command: specificAgentScript,
name: 'subagent-stop-specific-agent-hook',
timeout: 5000,
},
],
},
],
},
},
});
// This should trigger the hook since we're launching a bash subagent
const result = await rig.run(
'Use the Task tool to create a bash subagent that says "hello"',
);
expect(result).toBeDefined();
});
it('should match all agent types with wildcard matcher', async () => {
const wildcardScript =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Wildcard matcher allowed all agent types at stop"}}\'';
await rig.setup('subagent-stop-matcher-wildcard', {
settings: {
disableAllHooks: false,
hooks: {
SubagentStop: [
{
matcher: '*',
hooks: [
{
type: 'command',
command: wildcardScript,
name: 'subagent-stop-wildcard-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run(
'Use the Task tool to create a bash subagent that says "hello"',
);
expect(result).toBeDefined();
});
});
});
// ==========================================================================
// Notification Hooks
// Triggered when various notification events occur
// ==========================================================================
describe('Notification Hooks', () => {
describe('Idle Prompt Notifications', () => {
it('should handle idle prompt notifications correctly', async () => {
const idlePromptScript =
'echo \'{"additionalContext": "Idle prompt notification processed"}\'';
await rig.setup('notification-idle-prompt', {
settings: {
disableAllHooks: false,
hooks: {
Notification: [
{
hooks: [
{
type: 'command',
command: idlePromptScript,
name: 'notification-idle-prompt-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Simulate an idle prompt scenario - this might involve simulating a timeout
const result = await rig.run('Say idle prompt notification test');
expect(result).toBeDefined();
});
it('should process multiple idle prompt notifications', async () => {
const idlePromptScript1 =
'echo \'{"additionalContext": "First idle prompt notification"}\'';
const idlePromptScript2 =
'echo \'{"additionalContext": "Second idle prompt notification"}\'';
await rig.setup('notification-idle-prompt-multiple', {
settings: {
disableAllHooks: false,
hooks: {
Notification: [
{
hooks: [
{
type: 'command',
command: idlePromptScript1,
name: 'notification-idle-prompt-hook-1',
timeout: 5000,
},
{
type: 'command',
command: idlePromptScript2,
name: 'notification-idle-prompt-hook-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run(
'Say multiple idle prompt notification test',
);
expect(result).toBeDefined();
});
});
describe('Elicitation Dialog Notifications', () => {
it('should handle elication dialog notifications correctly', async () => {
const elicationDialogScript =
'echo \'{"additionalContext": "Elicitation dialog notification processed"}\'';
await rig.setup('notification-elication-dialog', {
settings: {
disableAllHooks: false,
hooks: {
Notification: [
{
hooks: [
{
type: 'command',
command: elicationDialogScript,
name: 'notification-elication-dialog-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Simulate an elication dialog scenario
const result = await rig.run('Say elication dialog notification test');
expect(result).toBeDefined();
});
it('should handle multiple elication dialog notifications', async () => {
const elicationDialogScript1 =
'echo \'{"additionalContext": "First elication dialog notification"}\'';
const elicationDialogScript2 =
'echo \'{"additionalContext": "Second elication dialog notification"}\'';
await rig.setup('notification-elication-dialog-multiple', {
settings: {
disableAllHooks: false,
hooks: {
Notification: [
{
hooks: [
{
type: 'command',
command: elicationDialogScript1,
name: 'notification-elication-dialog-hook-1',
timeout: 5000,
},
{
type: 'command',
command: elicationDialogScript2,
name: 'notification-elication-dialog-hook-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run(
'Say multiple elication dialog notification test',
);
expect(result).toBeDefined();
});
it('should handle elication dialog notification with error', async () => {
await rig.setup('notification-elication-dialog-error', {
settings: {
disableAllHooks: false,
hooks: {
Notification: [
{
hooks: [
{
type: 'command',
command: 'nonexistent_command_xyz',
name: 'notification-elication-dialog-error-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Error should be handled gracefully and not block execution
const result = await rig.run('Say elication dialog error test');
expect(result).toBeDefined();
});
});
describe('Multiple Notification Hooks', () => {
it('should handle multiple different notification types correctly', async () => {
const notificationScript1 =
'echo \'{"additionalContext": "Generic notification 1"}\'';
const notificationScript2 =
'echo \'{"additionalContext": "Generic notification 2"}\'';
await rig.setup('notification-multiple-different', {
settings: {
disableAllHooks: false,
hooks: {
Notification: [
{
hooks: [
{
type: 'command',
command: notificationScript1,
name: 'notification-multiple-hook-1',
timeout: 5000,
},
{
type: 'command',
command: notificationScript2,
name: 'notification-multiple-hook-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run(
'Say multiple different notification test',
);
expect(result).toBeDefined();
});
});
describe('Notification Hook Error Handling', () => {
it('should handle missing command gracefully', async () => {
await rig.setup('notification-missing-command', {
settings: {
disableAllHooks: false,
hooks: {
Notification: [
{
hooks: [
{
type: 'command',
command: '', // Empty command
name: 'notification-empty-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Empty command should be skipped gracefully
const result = await rig.run('Say missing command test');
expect(result).toBeDefined();
});
it('should handle non-executable command gracefully', async () => {
await rig.setup('notification-non-executable', {
settings: {
disableAllHooks: false,
hooks: {
Notification: [
{
hooks: [
{
type: 'command',
command: '/nonexistent/path/to/command',
name: 'notification-non-exec-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Non-existent command should be handled gracefully
const result = await rig.run('Say non-executable command test');
expect(result).toBeDefined();
});
it('should handle command with non-zero exit code gracefully', async () => {
await rig.setup('notification-nonzero-exit', {
settings: {
disableAllHooks: false,
hooks: {
Notification: [
{
hooks: [
{
type: 'command',
command: 'echo "warning" >&2 && exit 1',
name: 'notification-nonzero-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Non-zero exit should be handled gracefully for notification hooks
const result = await rig.run('Say nonzero exit code test');
expect(result).toBeDefined();
});
it('should handle command timeout gracefully', async () => {
await rig.setup('notification-timeout', {
settings: {
disableAllHooks: false,
hooks: {
Notification: [
{
hooks: [
{
type: 'command',
command: 'sleep 10',
name: 'notification-timeout-hook',
timeout: 1000, // Very short timeout to trigger timeout condition
},
],
},
],
},
},
});
// Timeout should be handled gracefully
const result = await rig.run('Say timeout test');
expect(result).toBeDefined();
});
});
});
// ==========================================================================
// PreToolUse Hooks
// Triggered before a tool is executed
// ==========================================================================
describe('PreToolUse Hooks', () => {
describe('Allow Decision', () => {
it('should allow tool execution when hook returns allow decision', async () => {
const hookScript =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "Tool execution approved by pretooluse hook"}}\'';
await rig.setup('pretooluse-allow-decision', {
settings: {
disableAllHooks: false,
hooks: {
PreToolUse: [
{
hooks: [
{
type: 'command',
command: hookScript,
name: 'pretooluse-allow-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say hello world');
// Verify that the interaction completed successfully (the hook allowed execution)
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should allow tool execution with additional context from hook', async () => {
const hookScript =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "Security check passed by pretooluse hook", "additionalContext": "Security check passed by pretooluse hook"}}\'';
await rig.setup('pretooluse-allow-with-context', {
settings: {
disableAllHooks: false,
hooks: {
PreToolUse: [
{
hooks: [
{
type: 'command',
command: hookScript,
name: 'pretooluse-context-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say context test');
// Verify that the interaction completed successfully (the hook allowed execution)
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
describe('Block Decision', () => {
it('should block tool execution when hook returns block decision', async () => {
const blockScript =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Tool execution blocked by security policy in pretooluse"}}\'';
await rig.setup('pretooluse-block-decision', {
settings: {
disableAllHooks: false,
hooks: {
PreToolUse: [
{
hooks: [
{
type: 'command',
command: blockScript,
name: 'pretooluse-block-hook',
timeout: 5000,
},
],
},
],
},
},
});
// When PreToolUse hook blocks, the interaction should still return a response
const result = await rig.run('Say should be blocked');
// Verify that a response was received despite the block
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should block specific tools based on tool name matching', async () => {
const blockSpecificToolScript = `
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
if [ "$TOOL_NAME" = "write_file" ]; then
echo '{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "File writing blocked by pretooluse hook"}}'
else
echo '{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "Tool allowed by pretooluse hook"}}'
fi
`;
await rig.setup('pretooluse-block-specific-tool', {
settings: {
disableAllHooks: false,
hooks: {
PreToolUse: [
{
hooks: [
{
type: 'command',
command: blockSpecificToolScript,
name: 'pretooluse-block-specific-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Attempt to say something - should be blocked by the hook for write_file operations
const result = await rig.run('Say should be blocked');
// Verify that a response was received
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
// But other prompts should still work
const readResult = await rig.run('Say hello from other tools');
expect(readResult).toBeDefined();
expect(readResult.length).toBeGreaterThan(0);
});
});
describe('Matcher Scenarios', () => {
it('should match specific tools with regex matcher', async () => {
const specificToolScript =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "Specific tool matched and allowed by pretooluse", "additionalContext": "Specific tool matched and allowed by pretooluse"}}\'';
await rig.setup('pretooluse-matcher-specific', {
settings: {
disableAllHooks: false,
hooks: {
PreToolUse: [
{
matcher: 'write_file|read_file',
hooks: [
{
type: 'command',
command: specificToolScript,
name: 'pretooluse-specific-tool-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say matcher test');
// Verify that the interaction completed successfully (the hook allowed execution)
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should match all tools with wildcard matcher', async () => {
const wildcardScript =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "Wildcard matcher allowed all tools in pretooluse", "additionalContext": "Wildcard matcher allowed all tools in pretooluse"}}\'';
await rig.setup('pretooluse-matcher-wildcard', {
settings: {
disableAllHooks: false,
hooks: {
PreToolUse: [
{
matcher: '*',
hooks: [
{
type: 'command',
command: wildcardScript,
name: 'pretooluse-wildcard-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say wildcard test');
// Verify that the interaction completed successfully (the hook allowed execution)
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should not execute when matcher does not match', async () => {
const noMatchScript =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "Should not execute in pretooluse", "additionalContext": "Should not execute in pretooluse"}}\'';
await rig.setup('pretooluse-matcher-no-match', {
settings: {
disableAllHooks: false,
hooks: {
PreToolUse: [
{
matcher: 'nonexistent_tool', // This won't match any real tool
hooks: [
{
type: 'command',
command: noMatchScript,
name: 'pretooluse-no-match-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say no match test');
// Verify that the interaction completed successfully (the hook allowed execution)
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
describe('Error Handling', () => {
it('should continue execution when hook exits with non-blocking error', async () => {
await rig.setup('pretooluse-nonblocking-error', {
settings: {
disableAllHooks: false,
hooks: {
PreToolUse: [
{
hooks: [
{
type: 'command',
command: 'echo warning && exit 1',
name: 'pretooluse-error-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say error test');
// Verify that the interaction completed successfully despite the hook error
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should continue execution when hook command does not exist', async () => {
await rig.setup('pretooluse-missing-command', {
settings: {
disableAllHooks: false,
hooks: {
PreToolUse: [
{
hooks: [
{
type: 'command',
command: '/nonexistent/pretooluse/command',
name: 'pretooluse-missing-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say missing test');
// Verify that the interaction completed successfully despite the missing hook command
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
describe('Multiple PreToolUse Hooks', () => {
it('should execute multiple parallel PreToolUse hooks', async () => {
const script1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Parallel pretooluse hook 1"}}\'';
const script2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Parallel pretooluse hook 2"}}\'';
await rig.setup('pretooluse-multi-parallel', {
settings: {
disableAllHooks: false,
hooks: {
PreToolUse: [
{
hooks: [
{
type: 'command',
command: script1,
name: 'pretooluse-parallel-1',
timeout: 5000,
},
{
type: 'command',
command: script2,
name: 'pretooluse-parallel-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say parallel test');
// Verify that the interaction completed successfully with multiple parallel hooks
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should execute sequential PreToolUse hooks in order', async () => {
const script1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Sequential pretooluse hook 1"}}\'';
const script2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Sequential pretooluse hook 2"}}\'';
await rig.setup('pretooluse-multi-sequential', {
settings: {
disableAllHooks: false,
hooks: {
PreToolUse: [
{
sequential: true,
hooks: [
{
type: 'command',
command: script1,
name: 'pretooluse-seq-1',
timeout: 5000,
},
{
type: 'command',
command: script2,
name: 'pretooluse-seq-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say sequential test');
// Verify that the interaction completed successfully with multiple sequential hooks
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should block when one of multiple parallel hooks returns block', async () => {
const allowScript = 'echo \'{"decision": "allow"}\'';
const blockScript =
'echo \'{"decision": "block", "reason": "Blocked by security policy in parallel pretooluse"}\'';
await rig.setup('pretooluse-multi-one-blocks', {
settings: {
disableAllHooks: false,
hooks: {
PreToolUse: [
{
hooks: [
{
type: 'command',
command: allowScript,
name: 'pretooluse-allow-hook',
timeout: 5000,
},
{
type: 'command',
command: blockScript,
name: 'pretooluse-block-hook',
timeout: 5000,
},
],
},
],
},
},
});
// When one hook blocks, the tool should not execute
const result = await rig.run('Say should be blocked');
// Verify that a response was received despite the block
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should block when first sequential hook returns block', async () => {
const blockScript =
'echo \'{"decision": "block", "reason": "First hook blocks in sequential pretooluse"}\'';
const allowScript = 'echo \'{"decision": "allow"}\'';
await rig.setup('pretooluse-seq-first-blocks', {
settings: {
disableAllHooks: false,
hooks: {
PreToolUse: [
{
sequential: true,
hooks: [
{
type: 'command',
command: blockScript,
name: 'pretooluse-seq-block-hook',
timeout: 5000,
},
{
type: 'command',
command: allowScript,
name: 'pretooluse-seq-allow-hook',
timeout: 5000,
},
],
},
],
},
},
});
// When the first hook blocks, the tool should not execute
const result = await rig.run('Say should be blocked');
// Verify that a response was received despite the block
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should concatenate additional context from multiple hooks', async () => {
const context1 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Context from pretooluse hook 1"}}\'';
const context2 =
'echo \'{"decision": "allow", "hookSpecificOutput": {"additionalContext": "Context from pretooluse hook 2"}}\'';
await rig.setup('pretooluse-multi-context', {
settings: {
disableAllHooks: false,
hooks: {
PreToolUse: [
{
hooks: [
{
type: 'command',
command: context1,
name: 'pretooluse-ctx-1',
timeout: 5000,
},
{
type: 'command',
command: context2,
name: 'pretooluse-ctx-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say multi context test');
// Verify that the interaction completed successfully with multiple context hooks
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
});
// ==========================================================================
// PostToolUse Hooks
// Triggered after a tool executes successfully
// ==========================================================================
describe('PostToolUse Hooks', () => {
describe('Basic Functionality', () => {
it('should execute PostToolUse hook after successful tool execution', async () => {
const hookScript =
'echo \'{"decision": "allow", "reason": "Tool execution logged by posttooluse hook", "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "Tool execution logged by posttooluse hook"}}\'';
await rig.setup('posttooluse-basic', {
settings: {
disableAllHooks: false,
hooks: {
PostToolUse: [
{
hooks: [
{
type: 'command',
command: hookScript,
name: 'posttooluse-basic-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say posttooluse test');
// Verify that the interaction completed successfully with the posttooluse hook
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
describe('Matcher Scenarios', () => {
it('should match specific tools with regex matcher', async () => {
const specificToolScript =
'echo \'{"decision": "allow", "reason": "Specific tool matched by posttooluse", "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "Specific tool matched by posttooluse"}}\'';
await rig.setup('posttooluse-matcher-specific', {
settings: {
disableAllHooks: false,
hooks: {
PostToolUse: [
{
matcher: 'write_file|read_file',
hooks: [
{
type: 'command',
command: specificToolScript,
name: 'posttooluse-specific-tool-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say matcher test');
// Verify that the interaction completed successfully with the posttooluse hook
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should match all tools with wildcard matcher', async () => {
const wildcardScript =
'echo \'{"decision": "allow", "reason": "Wildcard matcher processed all tools in posttooluse", "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "Wildcard matcher processed all tools in posttooluse"}}\'';
await rig.setup('posttooluse-matcher-wildcard', {
settings: {
disableAllHooks: false,
hooks: {
PostToolUse: [
{
matcher: '*',
hooks: [
{
type: 'command',
command: wildcardScript,
name: 'posttooluse-wildcard-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say wildcard test');
// Verify that the interaction completed successfully with the posttooluse hook
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should not execute when matcher does not match', async () => {
const noMatchScript =
'echo \'{"decision": "allow", "reason": "Should not execute in posttooluse", "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "Should not execute in posttooluse"}}\'';
await rig.setup('posttooluse-matcher-no-match', {
settings: {
disableAllHooks: false,
hooks: {
PostToolUse: [
{
matcher: 'nonexistent_tool', // This won't match any real tool
hooks: [
{
type: 'command',
command: noMatchScript,
name: 'posttooluse-no-match-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say no match test');
// Verify that the interaction completed successfully (the hook didn't block execution since it didn't match)
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
describe('Multiple PostToolUse Hooks', () => {
it('should execute multiple parallel PostToolUse hooks', async () => {
const script1 =
'echo \'{"decision": "allow", "reason": "Parallel posttooluse hook 1", "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "Parallel posttooluse hook 1"}}\'';
const script2 =
'echo \'{"decision": "allow", "reason": "Parallel posttooluse hook 2", "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "Parallel posttooluse hook 2"}}\'';
await rig.setup('posttooluse-multi-parallel', {
settings: {
disableAllHooks: false,
hooks: {
PostToolUse: [
{
hooks: [
{
type: 'command',
command: script1,
name: 'posttooluse-parallel-1',
timeout: 5000,
},
{
type: 'command',
command: script2,
name: 'posttooluse-parallel-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say parallel test');
// Verify that the interaction completed successfully with multiple posttooluse hooks
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should execute sequential PostToolUse hooks in order', async () => {
const script1 =
'echo \'{"decision": "allow", "reason": "Sequential posttooluse hook 1", "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "Sequential posttooluse hook 1"}}\'';
const script2 =
'echo \'{"decision": "allow", "reason": "Sequential posttooluse hook 2", "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "Sequential posttooluse hook 2"}}\'';
await rig.setup('posttooluse-multi-sequential', {
settings: {
disableAllHooks: false,
hooks: {
PostToolUse: [
{
sequential: true,
hooks: [
{
type: 'command',
command: script1,
name: 'posttooluse-seq-1',
timeout: 5000,
},
{
type: 'command',
command: script2,
name: 'posttooluse-seq-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say sequential test');
// Verify that the interaction completed successfully with multiple sequential posttooluse hooks
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should concatenate additional context from multiple hooks', async () => {
const context1 =
'echo \'{"decision": "allow", "reason": "Context from posttooluse hook 1", "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "Context from posttooluse hook 1"}}\'';
const context2 =
'echo \'{"decision": "allow", "reason": "Context from posttooluse hook 2", "hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "Context from posttooluse hook 2"}}\'';
await rig.setup('posttooluse-multi-context', {
settings: {
disableAllHooks: false,
hooks: {
PostToolUse: [
{
hooks: [
{
type: 'command',
command: context1,
name: 'posttooluse-ctx-1',
timeout: 5000,
},
{
type: 'command',
command: context2,
name: 'posttooluse-ctx-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say multi context test');
// Verify that the interaction completed successfully with multiple context posttooluse hooks
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
});
// ==========================================================================
// PostToolUseFailure Hooks
// Triggered after a tool fails to execute
// ==========================================================================
describe('PostToolUseFailure Hooks', () => {
describe('Basic Functionality', () => {
it('should execute PostToolUseFailure hook after failed tool execution', async () => {
const hookScript =
'echo \'{"hookSpecificOutput": {"additionalContext": "Tool failure logged by posttoolusefailure hook"}}\'';
await rig.setup('posttoolusefailure-basic', {
settings: {
disableAllHooks: false,
hooks: {
PostToolUseFailure: [
{
hooks: [
{
type: 'command',
command: hookScript,
name: 'posttoolusefailure-basic-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Attempt to read a non-existent file to trigger a tool failure
const result = await rig.run('Read the nonexistent-file.txt file');
// The tool should fail, but the hook should still execute
expect(result).toBeDefined();
});
it('should receive tool failure details in hook input', async () => {
const hookScript = `
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
ERROR_MESSAGE=$(echo "$INPUT" | jq -r '.error_message // empty')
echo '{"hookSpecificOutput": {"additionalContext": "Failed ' + '$TOOL_NAME' + ' with error: ' + '$ERROR_MESSAGE' + '"}}'
`;
await rig.setup('posttoolusefailure-with-details', {
settings: {
disableAllHooks: false,
hooks: {
PostToolUseFailure: [
{
hooks: [
{
type: 'command',
command: hookScript,
name: 'posttoolusefailure-details-hook',
timeout: 5000,
},
],
},
],
},
},
});
// Attempt to read a non-existent file to trigger a tool failure
const result = await rig.run('Read the nonexistent-details.txt file');
// The tool should fail, but the hook should still execute and process the error details
expect(result).toBeDefined();
});
});
});
// ==========================================================================
// PreCompact Hooks
// Triggered before conversation compaction
// ==========================================================================
describe('PreCompact Hooks', () => {
describe('Basic Functionality', () => {
it('should execute PreCompact hook before conversation compaction', async () => {
const hookScript =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreCompact", "additionalContext": "Compaction approved by precompact hook"}}\'';
await rig.setup('precompact-basic', {
settings: {
disableAllHooks: false,
hooks: {
PreCompact: [
{
hooks: [
{
type: 'command',
command: hookScript,
name: 'precompact-basic-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say precompact test');
// Verify that the interaction completed successfully with the precompact hook
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should receive compaction details in hook input', async () => {
const hookScript = `
INPUT=$(cat)
TRIGGER=$(echo "$INPUT" | jq -r '.trigger')
CUSTOM_INSTRUCTIONS=$(echo "$INPUT" | jq -r '.custom_instructions // empty')
echo '{"hookSpecificOutput": {"hookEventName": "PreCompact", "additionalContext": "Compaction triggered by: ' + '$TRIGGER' + ', Instructions length: $(echo "$CUSTOM_INSTRUCTIONS" | wc -c)"}}'
`;
await rig.setup('precompact-with-details', {
settings: {
disableAllHooks: false,
hooks: {
PreCompact: [
{
hooks: [
{
type: 'command',
command: hookScript,
name: 'precompact-details-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say precompact details test');
// Verify that the interaction completed successfully with the precompact hook
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
describe('Context Scenarios', () => {
it('should provide additional context when hook returns context', async () => {
const contextScript =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreCompact", "additionalContext": "Compaction context provided by precompact hook"}}\'';
await rig.setup('precompact-context', {
settings: {
disableAllHooks: false,
hooks: {
PreCompact: [
{
hooks: [
{
type: 'command',
command: contextScript,
name: 'precompact-context-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say precompact context test');
// Verify that the interaction completed successfully with context
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
describe('Matcher Scenarios', () => {
it('should match all compaction triggers with wildcard matcher', async () => {
const wildcardScript =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreCompact", "additionalContext": "Wildcard matcher allowed compaction in precompact"}}\'';
await rig.setup('precompact-matcher-wildcard', {
settings: {
disableAllHooks: false,
hooks: {
PreCompact: [
{
matcher: '*',
hooks: [
{
type: 'command',
command: wildcardScript,
name: 'precompact-wildcard-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say precompact wildcard test');
// Verify that the interaction completed successfully with the wildcard matcher
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should not execute when matcher does not match', async () => {
const noMatchScript =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreCompact", "additionalContext": "Should not execute in precompact"}}\'';
await rig.setup('precompact-matcher-no-match', {
settings: {
disableAllHooks: false,
hooks: {
PreCompact: [
{
matcher: 'nonexistent_trigger', // This won't match any real trigger
hooks: [
{
type: 'command',
command: noMatchScript,
name: 'precompact-no-match-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say precompact no match test');
// Verify that the interaction completed successfully (the hook didn't block execution since it didn't match)
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
describe('Multiple PreCompact Hooks', () => {
it('should execute multiple parallel PreCompact hooks', async () => {
const script1 =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreCompact", "additionalContext": "Parallel precompact hook 1"}}\'';
const script2 =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreCompact", "additionalContext": "Parallel precompact hook 2"}}\'';
await rig.setup('precompact-multi-parallel', {
settings: {
disableAllHooks: false,
hooks: {
PreCompact: [
{
hooks: [
{
type: 'command',
command: script1,
name: 'precompact-parallel-1',
timeout: 5000,
},
{
type: 'command',
command: script2,
name: 'precompact-parallel-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say precompact parallel test');
// Verify that the interaction completed successfully with multiple parallel hooks
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should execute sequential PreCompact hooks in order', async () => {
const script1 =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreCompact", "additionalContext": "Sequential precompact hook 1"}}\'';
const script2 =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreCompact", "additionalContext": "Sequential precompact hook 2"}}\'';
await rig.setup('precompact-multi-sequential', {
settings: {
disableAllHooks: false,
hooks: {
PreCompact: [
{
sequential: true,
hooks: [
{
type: 'command',
command: script1,
name: 'precompact-seq-1',
timeout: 5000,
},
{
type: 'command',
command: script2,
name: 'precompact-seq-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say precompact sequential test');
// Verify that the interaction completed successfully with multiple sequential hooks
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should concatenate additional context from multiple hooks', async () => {
const context1 =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreCompact", "additionalContext": "Context from precompact hook 1"}}\'';
const context2 =
'echo \'{"hookSpecificOutput": {"hookEventName": "PreCompact", "additionalContext": "Context from precompact hook 2"}}\'';
await rig.setup('precompact-multi-context', {
settings: {
disableAllHooks: false,
hooks: {
PreCompact: [
{
hooks: [
{
type: 'command',
command: context1,
name: 'precompact-ctx-1',
timeout: 5000,
},
{
type: 'command',
command: context2,
name: 'precompact-ctx-2',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say precompact multi context test');
// Verify that the interaction completed successfully with multiple context hooks
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
describe('Error Handling', () => {
it('should continue execution when hook exits with error', async () => {
await rig.setup('precompact-error', {
settings: {
disableAllHooks: false,
hooks: {
PreCompact: [
{
hooks: [
{
type: 'command',
command: 'echo warning && exit 1',
name: 'precompact-error-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say precompact error test');
// Verify that the interaction completed successfully despite the hook error
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should continue execution when hook command does not exist', async () => {
await rig.setup('precompact-missing-command', {
settings: {
disableAllHooks: false,
hooks: {
PreCompact: [
{
hooks: [
{
type: 'command',
command: '/nonexistent/precompact/command',
name: 'precompact-missing-hook',
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run('Say precompact missing test');
// Verify that the interaction completed successfully despite the missing hook command
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it('should handle hook timeout gracefully', async () => {
await rig.setup('precompact-timeout', {
settings: {
disableAllHooks: false,
hooks: {
PreCompact: [
{
hooks: [
{
type: 'command',
command: 'sleep 60',
name: 'precompact-timeout-hook',
timeout: 1000, // 1 second timeout
},
],
},
],
},
},
});
const result = await rig.run('Say precompact timeout test');
// Verify that the interaction completed successfully despite the hook timeout
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
});
});