Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
Storage,
SessionEndReason,
SessionStartSource,
type PermissionMode,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import { validateAuthMethod } from '../config/auth.js';
Expand Down Expand Up @@ -308,7 +309,11 @@ export const AppContainer = (props: AppContainerProps) => {

if (hookSystem) {
hookSystem
.fireSessionStartEvent(sessionStartSource, config.getModel() ?? '')
.fireSessionStartEvent(
sessionStartSource,
config.getModel() ?? '',
String(config.getApprovalMode()) as PermissionMode,
)
.then(() => {
debugLogger.debug('SessionStart event completed successfully');
})
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/commands/clearCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ describe('clearCommand', () => {
}),
getModel: () => 'test-model',
getToolRegistry: () => undefined,
getApprovalMode: () => 'default',
},
},
session: {
Expand Down Expand Up @@ -108,6 +109,7 @@ describe('clearCommand', () => {
expect(mockFireSessionStartEvent).toHaveBeenCalledWith(
SessionStartSource.Clear,
'test-model',
expect.any(String), // permissionMode
);

// SessionEnd should be called before SessionStart
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/commands/clearCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
SessionStartSource,
ToolNames,
SkillTool,
type PermissionMode,
} from '@qwen-code/qwen-code-core';

export const clearCommand: SlashCommand = {
Expand Down Expand Up @@ -72,6 +73,7 @@ export const clearCommand: SlashCommand = {
?.fireSessionStartEvent(
SessionStartSource.Clear,
config.getModel() ?? '',
String(config.getApprovalMode()) as PermissionMode,
);
} catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/hooks/useResumeCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
SessionService,
type Config,
SessionStartSource,
type PermissionMode,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
Expand Down Expand Up @@ -78,6 +79,7 @@ export function useResumeCommand(
?.fireSessionStartEvent(
SessionStartSource.Resume,
config.getModel() ?? '',
String(config.getApprovalMode()) as PermissionMode,
);
} catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,19 +810,33 @@ export class Config {
return;
}

// Check if request was aborted
if (request.signal?.aborted) {
this.messageBus?.publish({
type: MessageBusType.HOOK_EXECUTION_RESPONSE,
correlationId: request.correlationId,
success: false,
error: new Error('Hook execution cancelled (aborted)'),
} as HookExecutionResponse);
return;
}

// Execute the appropriate hook based on eventName
let result;
const input = request.input || {};
const signal = request.signal;
switch (request.eventName) {
case 'UserPromptSubmit':
result = await hookSystem.fireUserPromptSubmitEvent(
(input['prompt'] as string) || '',
signal,
);
break;
case 'Stop':
result = await hookSystem.fireStopEvent(
(input['stop_hook_active'] as boolean) || false,
(input['last_assistant_message'] as string) || '',
signal,
);
break;
case 'PreToolUse': {
Expand All @@ -832,6 +846,7 @@ export class Config {
(input['tool_use_id'] as string) || '',
(input['permission_mode'] as PermissionMode | undefined) ??
PermissionMode.Default,
signal,
);
break;
}
Expand All @@ -842,6 +857,7 @@ export class Config {
(input['tool_response'] as Record<string, unknown>) || {},
(input['tool_use_id'] as string) || '',
(input['permission_mode'] as PermissionMode) || 'default',
signal,
);
break;
case 'PostToolUseFailure':
Expand All @@ -852,6 +868,7 @@ export class Config {
(input['error'] as string) || '',
input['is_interrupt'] as boolean | undefined,
(input['permission_mode'] as PermissionMode) || 'default',
signal,
);
break;
case 'Notification':
Expand All @@ -860,6 +877,7 @@ export class Config {
(input['notification_type'] as NotificationType) ||
'permission_prompt',
(input['title'] as string) || undefined,
signal,
);
break;
case 'PermissionRequest':
Expand All @@ -871,6 +889,7 @@ export class Config {
(input['permission_suggestions'] as
| PermissionSuggestion[]
| undefined) || undefined,
signal,
);
break;
case 'SubagentStart':
Expand All @@ -879,6 +898,7 @@ export class Config {
(input['agent_type'] as string) || '',
(input['permission_mode'] as PermissionMode) ||
PermissionMode.Default,
signal,
);
break;
case 'SubagentStop':
Expand All @@ -890,6 +910,7 @@ export class Config {
(input['stop_hook_active'] as boolean) || false,
(input['permission_mode'] as PermissionMode) ||
PermissionMode.Default,
signal,
);
break;
default:
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/confirmation-bus/message-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,17 @@ export class MessageBus extends EventEmitter {
request: Omit<TRequest, 'correlationId'>,
responseType: TResponse['type'],
timeoutMs: number = 60000,
signal?: AbortSignal,
): Promise<TResponse> {
const correlationId = randomUUID();

return new Promise<TResponse>((resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error('Request aborted'));
return;
}

const timeoutId = setTimeout(() => {
cleanup();
reject(new Error(`Request timed out waiting for ${responseType}`));
Expand All @@ -102,8 +109,20 @@ export class MessageBus extends EventEmitter {
const cleanup = () => {
clearTimeout(timeoutId);
this.unsubscribe(responseType, responseHandler);
if (signal) {
signal.removeEventListener('abort', abortHandler);
}
};

const abortHandler = () => {
cleanup();
reject(new Error('Request aborted'));
};

if (signal) {
signal.addEventListener('abort', abortHandler);
}

const responseHandler = (response: TResponse) => {
// Check if this response matches our request
if (
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/confirmation-bus/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ export interface HookExecutionRequest {
eventName: string;
input: Record<string, unknown>;
correlationId: string;
/** Optional AbortSignal to cancel hook execution */
signal?: AbortSignal;
}

export interface HookExecutionResponse {
Expand Down
36 changes: 28 additions & 8 deletions packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ export class GeminiClient {
return new Turn(this.getChat(), prompt_id);
}

const compressed = await this.tryCompressChat(prompt_id, false);
const compressed = await this.tryCompressChat(prompt_id, false, signal);

if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
yield { type: GeminiEventType.ChatCompressed, value: compressed };
Expand Down Expand Up @@ -677,7 +677,13 @@ export class GeminiClient {
}
// Fire Stop hook through MessageBus (only if hooks are enabled)
// This must be done before any early returns to ensure hooks are always triggered
if (hooksEnabled && messageBus && !turn.pendingToolCalls.length) {
if (
hooksEnabled &&
messageBus &&
!turn.pendingToolCalls.length &&
signal &&
!signal.aborted
) {
// Get response text from the chat history
const history = this.getHistory();
const lastModelMessage = history
Expand All @@ -700,26 +706,38 @@ export class GeminiClient {
stop_hook_active: true,
last_assistant_message: responseText,
},
signal,
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);

// Check if aborted after hook execution
if (signal.aborted) {
return turn;
}

const hookOutput = response.output
? createHookOutput('Stop', response.output)
: undefined;

const stopOutput = hookOutput as StopHookOutput | undefined;

// This should happen regardless of the hook's decision
if (stopOutput?.systemMessage) {
yield {
type: GeminiEventType.HookSystemMessage,
value: stopOutput.systemMessage,
};
}

// For Stop hooks, blocking/stop execution should force continuation
if (
stopOutput?.isBlockingDecision() ||
stopOutput?.shouldStopExecution()
) {
// Emit system message if provided (e.g., "🔄 Ralph iteration 5")
if (stopOutput.systemMessage) {
yield {
type: GeminiEventType.HookSystemMessage,
value: stopOutput.systemMessage,
};
// Check if aborted before continuing
if (signal.aborted) {
return turn;
}

const continueReason = stopOutput.getEffectiveReason();
Expand Down Expand Up @@ -844,6 +862,7 @@ export class GeminiClient {
async tryCompressChat(
prompt_id: string,
force: boolean = false,
signal?: AbortSignal,
): Promise<ChatCompressionInfo> {
const compressionService = new ChatCompressionService();

Expand All @@ -854,6 +873,7 @@ export class GeminiClient {
this.config.getModel(),
this.config,
this.hasFailedCompressionAttempt,
signal,
);

// Handle compression result
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/core/toolHookTriggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export async function firePreToolUseHook(
toolInput: Record<string, unknown>,
toolUseId: string,
permissionMode: string,
signal?: AbortSignal,
): Promise<PreToolUseHookResult> {
if (!messageBus) {
return { shouldProceed: true };
Expand All @@ -100,6 +101,7 @@ export async function firePreToolUseHook(
tool_input: toolInput,
tool_use_id: toolUseId,
},
signal,
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
Expand Down Expand Up @@ -178,6 +180,7 @@ export async function firePostToolUseHook(
toolResponse: Record<string, unknown>,
toolUseId: string,
permissionMode: string,
signal?: AbortSignal,
): Promise<PostToolUseHookResult> {
if (!messageBus) {
return { shouldStop: false };
Expand All @@ -198,6 +201,7 @@ export async function firePostToolUseHook(
tool_response: toolResponse,
tool_use_id: toolUseId,
},
signal,
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
Expand Down Expand Up @@ -255,6 +259,7 @@ export async function firePostToolUseFailureHook(
errorMessage: string,
isInterrupt?: boolean,
permissionMode?: string,
signal?: AbortSignal,
): Promise<PostToolUseFailureHookResult> {
if (!messageBus) {
return {};
Expand All @@ -276,6 +281,7 @@ export async function firePostToolUseFailureHook(
error: errorMessage,
is_interrupt: isInterrupt,
},
signal,
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
Expand Down Expand Up @@ -319,6 +325,7 @@ export async function fireNotificationHook(
message: string,
notificationType: NotificationType,
title?: string,
signal?: AbortSignal,
): Promise<NotificationHookResult> {
if (!messageBus) {
return {};
Expand All @@ -337,6 +344,7 @@ export async function fireNotificationHook(
notification_type: notificationType,
title,
},
signal,
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
Expand Down Expand Up @@ -390,6 +398,7 @@ export async function firePermissionRequestHook(
toolInput: Record<string, unknown>,
permissionMode: string,
permissionSuggestions?: PermissionSuggestion[],
signal?: AbortSignal,
): Promise<PermissionRequestHookResult> {
if (!messageBus) {
return { hasDecision: false };
Expand All @@ -409,6 +418,7 @@ export async function firePermissionRequestHook(
permission_mode: permissionMode,
permission_suggestions: permissionSuggestions,
},
signal,
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/hooks/hookEventHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,7 @@ describe('HookEventHandler', () => {
expect.any(Object), // input object
expect.any(Function), // onHookStart callback
expect.any(Function), // onHookEnd callback
undefined, // signal
);
});

Expand Down
Loading
Loading