Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2262,6 +2262,18 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
PostToolBatch: {
type: 'array',
label: 'Post Tool Batch Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute once after all tool calls in a batch resolve.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
SessionStart: {
type: 'array',
label: 'Session Start Hooks',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cleanup } from 'ink-testing-library';
import { HookEventName } from '@qwen-code/qwen-code-core';
import { HooksManagementDialog } from './HooksManagementDialog.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { loadSettings, SettingScope } from '../../../config/settings.js';
import type { Key } from '../../contexts/KeypressContext.js';
import { DISPLAY_HOOK_EVENTS } from './constants.js';

vi.mock('../../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
Expand Down Expand Up @@ -338,14 +340,15 @@ describe('HooksManagementDialog', () => {
expect(lastFrame()).toContain('Hooks');
});

for (let i = 0; i < 6; i++) {
const stopEventIndex = DISPLAY_HOOK_EVENTS.indexOf(HookEventName.Stop);
for (let i = 0; i < stopEventIndex; i++) {
pressKey('down');
await vi.waitFor(() => {
expect(lastFrame()).toContain(`❯ ${i + 2}.`);
});
}
await vi.waitFor(() => {
expect(lastFrame()).toContain('7. Stop');
expect(lastFrame()).toContain(`${stopEventIndex + 1}. Stop`);
});
pressKey('return');
await vi.waitFor(() => {
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/ui/components/hooks/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ describe('hooks constants', () => {
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PreToolUse);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PostToolUse);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PostToolUseFailure);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PostToolBatch);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.Notification);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.UserPromptSubmit);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SessionStart);
Expand All @@ -245,8 +246,8 @@ describe('hooks constants', () => {
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.TodoCompleted);
});

it('should have 17 events', () => {
expect(DISPLAY_HOOK_EVENTS).toHaveLength(17);
it('should have 18 events', () => {
expect(DISPLAY_HOOK_EVENTS).toHaveLength(18);
});
});

Expand All @@ -268,6 +269,7 @@ describe('hooks constants', () => {

it('returns false for events without matchers', () => {
expect(supportsMatchers(HookEventName.Stop)).toBe(false);
expect(supportsMatchers(HookEventName.PostToolBatch)).toBe(false);
expect(supportsMatchers(HookEventName.UserPromptSubmit)).toBe(false);
expect(supportsMatchers(HookEventName.TodoCreated)).toBe(false);
expect(supportsMatchers(HookEventName.TodoCompleted)).toBe(false);
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/ui/components/hooks/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export function getHookExitCodes(eventName: string): HookExitCode[] {
{ code: 2, description: t('show stderr to model immediately') },
{ code: 'Other', description: t('show stderr to user only') },
],
[HookEventName.PostToolBatch]: [
{ code: 0, description: t('stdout shown in transcript mode (ctrl+o)') },
{ code: 2, description: t('show stderr to model immediately') },
{ code: 'Other', description: t('show stderr to user only') },
],
[HookEventName.Notification]: [
{ code: 0, description: t('stdout/stderr not shown') },
{ code: 'Other', description: t('show stderr to user only') },
Expand Down Expand Up @@ -144,6 +149,9 @@ export function getHookShortDescription(eventName: string): string {
[HookEventName.PreToolUse]: t('Before tool execution'),
[HookEventName.PostToolUse]: t('After tool execution'),
[HookEventName.PostToolUseFailure]: t('After tool execution fails'),
[HookEventName.PostToolBatch]: t(
'After all tool calls in a batch resolve',
),
[HookEventName.Notification]: t('When notifications are sent'),
[HookEventName.UserPromptSubmit]: t('When the user submits a prompt'),
[HookEventName.SessionStart]: t('When a new session is started'),
Expand Down Expand Up @@ -187,6 +195,9 @@ export function getHookDescription(eventName: string): string {
[HookEventName.PostToolUseFailure]: t(
'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.',
),
[HookEventName.PostToolBatch]: t(
'Input to command is JSON with tool_calls, an array of resolved tool calls containing tool_name, tool_input, tool_use_id, and tool_response.',
),
[HookEventName.Notification]: t(
'Input to command is JSON with notification message and type.',
),
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ import {
type PermissionSuggestion,
type HookEventName,
type HookDefinition,
type PostToolBatchToolCall,
} from '../hooks/types.js';
import { fireNotificationHook } from '../core/toolHookTriggers.js';

Expand Down Expand Up @@ -1485,6 +1486,13 @@ export class Config {
signal,
);
break;
case 'PostToolBatch':
result = await hookSystem.firePostToolBatchEvent(
(input['tool_calls'] as PostToolBatchToolCall[]) || [],
(input['permission_mode'] as PermissionMode) || 'default',
signal,
);
break;
case 'Notification':
result = await hookSystem.fireNotificationEvent(
(input['message'] as string) || '',
Expand Down
Loading
Loading