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
38 changes: 19 additions & 19 deletions docs/users/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,25 +337,25 @@ When `ok` is `false`, Qwen Code will continue working and use the `reason` as co

Hooks fire at specific points during a Qwen Code session. Different events support different matchers to filter trigger conditions.

| Event | Triggered When | Matcher Target |
| :------------------- | :---------------------------------------------- | :------------------------------------------------------------- |
| `PreToolUse` | Before tool execution | Tool id (`write_file`, `read_file`, `run_shell_command`, etc.) |
| `PostToolUse` | After successful tool execution | Tool id |
| `PostToolUseFailure` | After tool execution fails | Tool id |
| `UserPromptSubmit` | Before supported model invocations | None |
| `SessionStart` | When session starts or resumes | Source (`startup`, `resume`, `clear`, `compact`) |
| `SessionEnd` | When session ends | Reason (`clear`, `logout`, `prompt_input_exit`, etc.) |
| `SessionDelete` | After an explicitly selected session is deleted | None |
| `MessageDisplay` | Repeatedly, as the reply streams | None (always fires) |
| `Stop` | When Claude prepares to conclude response | None (always fires) |
| `SubagentStart` | When subagent starts | Agent type (`Bash`, `Explorer`, `Plan`, etc.) |
| `SubagentStop` | When subagent stops | Agent type |
| `PreCompact` | Before conversation compaction | Trigger (`manual`, `auto`) |
| `Notification` | When notifications are sent | Type (`permission_prompt`, `idle_prompt`, `auth_success`) |
| `PermissionRequest` | When permission dialog is shown | Tool id |
| `PermissionDenied` | When tool permission is denied | Tool id |
| `TodoCreated` | When a new todo item is created | None (always fires) |
| `TodoCompleted` | When a todo item is marked as completed | None (always fires) |
| Event | Triggered When | Matcher Target |
| :------------------- | :----------------------------------------------- | :------------------------------------------------------------- |
| `PreToolUse` | Before tool execution | Tool id (`write_file`, `read_file`, `run_shell_command`, etc.) |
| `PostToolUse` | After successful tool execution | Tool id |
| `PostToolUseFailure` | After tool execution fails | Tool id |
| `UserPromptSubmit` | Before supported model invocations | None |
| `SessionStart` | When session starts or resumes | Source (`startup`, `resume`, `clear`, `compact`) |
| `SessionEnd` | When session ends | Reason (`clear`, `logout`, `prompt_input_exit`, etc.) |
| `SessionDelete` | After an explicitly selected session is deleted | None |
| `MessageDisplay` | Repeatedly, as the reply streams | None (always fires) |
| `Stop` | When Claude prepares to conclude response | None (always fires) |
| `SubagentStart` | When subagent starts | Agent type (`Bash`, `Explorer`, `Plan`, etc.) |
| `SubagentStop` | When subagent stops | Agent type |
| `PreCompact` | Before conversation compaction | Trigger (`manual`, `auto`) |
| `Notification` | When notifications are sent | Type (`permission_prompt`, `idle_prompt`, `auth_success`) |
| `PermissionRequest` | When permission dialog is shown | Tool id |
| `PermissionDenied` | When AUTO-mode classification denies a tool call | Tool id |
| `TodoCreated` | When a new todo item is created | None (always fires) |
| `TodoCompleted` | When a todo item is marked as completed | None (always fires) |

### Matcher Patterns

Expand Down
197 changes: 197 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27531,6 +27531,203 @@ describe('Session', () => {
);
});

function configureAutoModeShellFallback(options: {
callId: string;
command: string;
denialState: core.AutoModeDenialState;
classifierResults?: Array<Record<string, unknown>>;
}) {
let denialState = options.denialState;
const generateJson = vi.fn();
for (const result of options.classifierResults ?? []) {
generateJson.mockResolvedValueOnce(result);
}
const execute = vi.fn().mockResolvedValue({
llmContent: 'ok',
returnDisplay: 'ok',
});
const onConfirm = vi.fn().mockResolvedValue(undefined);
const invocation = {
params: { command: options.command },
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
getConfirmationDetails: vi.fn().mockResolvedValue({
type: 'exec',
title: 'Need permission',
command: options.command,
rootCommand: 'python',
onConfirm,
}),
getDescription: vi.fn().mockReturnValue('Run command'),
toolLocations: vi.fn().mockReturnValue([]),
execute,
};
mockToolRegistry.getTool.mockReturnValue({
name: core.ToolNames.SHELL,
kind: core.Kind.Execute,
build: vi.fn().mockReturnValue(invocation),
});
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.AUTO);
mockConfig.getCwd = vi.fn().mockReturnValue('/repo');
mockConfig.getPermissionManager = vi.fn().mockReturnValue(null);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(true);
mockConfig.getMessageBus = vi.fn().mockReturnValue(undefined);
mockConfig.getAutoModeSettings = vi.fn().mockReturnValue({});
mockConfig.getBaseLlmClient = vi.fn().mockReturnValue({ generateJson });
mockConfig.getAutoModeDenialState = vi
.fn()
.mockImplementation(() => denialState);
mockConfig.setAutoModeDenialState = vi
.fn()
.mockImplementation((next: core.AutoModeDenialState) => {
denialState = next;
});
(
mockLlmClient as unknown as {
getHistoryTail: ReturnType<typeof vi.fn>;
}
).getHistoryTail = vi.fn().mockReturnValue([]);
vi.mocked(mockClient.requestPermission).mockResolvedValueOnce({
outcome: {
outcome: 'selected',
optionId: core.ToolConfirmationOutcome.ProceedOnce,
},
});
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: options.callId,
name: core.ToolNames.SHELL,
args: { command: options.command },
},
],
},
},
]),
);

return {
execute,
generateJson,
getDenialState: () => denialState,
onConfirm,
};
}

it('routes an exact ACP retry to manual approval without reclassifying it', async () => {
const command = 'python -c "print(1)"';
const { execute, generateJson, getDenialState, onConfirm } =
configureAutoModeShellFallback({
callId: 'call-exact-auto-retry',
command,
denialState: {
consecutiveBlock: 1,
consecutiveUnavailable: 0,
totalBlock: 1,
totalUnavailable: 0,
pendingManualRetryFingerprint: core.getAutoModeActionFingerprint(
core.ToolNames.SHELL,
{ command },
'/repo',
),
},
});

await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'retry tool' }],
});

expect(generateJson).not.toHaveBeenCalled();
expect(mockClient.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
toolCall: expect.objectContaining({
content: expect.arrayContaining([
expect.objectContaining({
content: expect.objectContaining({
text: expect.stringContaining('previously blocked'),
}),
}),
]),
}),
}),
);
const permissionRequest = vi.mocked(mockClient.requestPermission).mock
.calls[0][0];
expect(permissionRequest.options).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
optionId:
core.ToolConfirmationOutcome.ProceedOnceAndSwitchToDefault,
}),
]),
);
expect(onConfirm).toHaveBeenCalledWith(
core.ToolConfirmationOutcome.ProceedOnce,
{ answers: undefined },
);
expect(execute).toHaveBeenCalledOnce();
expect(getDenialState()).toEqual({
consecutiveBlock: 0,
consecutiveUnavailable: 0,
totalBlock: 1,
totalUnavailable: 0,
});
});

it('routes the current ACP threshold block to manual approval', async () => {
const command = 'python -c "print(1)"';
const { execute, generateJson, getDenialState } =
configureAutoModeShellFallback({
callId: 'call-current-threshold',
command,
denialState: {
consecutiveBlock: 2,
consecutiveUnavailable: 0,
totalBlock: 2,
totalUnavailable: 0,
},
classifierResults: [
{ shouldBlock: true },
{
thinking: 'confirmed',
shouldBlock: true,
reason: 'unsafe command',
},
],
});

await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'run tool' }],
});

expect(generateJson).toHaveBeenCalledTimes(2);
expect(mockClient.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
toolCall: expect.objectContaining({
content: expect.arrayContaining([
expect.objectContaining({
content: expect.objectContaining({
text: expect.stringContaining('consecutive denial limit'),
}),
}),
]),
}),
}),
);
expect(execute).toHaveBeenCalledOnce();
expect(getDenialState()).toEqual({
consecutiveBlock: 0,
consecutiveUnavailable: 0,
totalBlock: 3,
totalUnavailable: 0,
});
});

describe('in-session cron MessageDisplay', () => {
/** Mock scheduler that delivers exactly one in-session job through `start`. */
function schedulerFiring(job: { prompt: string }) {
Expand Down
48 changes: 36 additions & 12 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type {
ChatCompressionInfo,
AutoModeDecision,
AutoModeOutcome,
AutoModeFallbackConfirmation,
GoalRecord,
GoalRuntime,
GoalSnapshotV2,
Expand Down Expand Up @@ -133,16 +134,17 @@ import {
getStopHookContinuationReason,
formatStopHookBlockingCapWarning,
applyAutoModeDecision,
decorateClassifierUnavailableConfirmation,
decorateAutoModeFallbackConfirmation,
evaluateAutoMode,
getAutoModeActionFingerprint,
getAutoModePermissionDeniedReason,
prepareAutoModeFallback,
isApproveOutcome,
isDenialFallbackReason,
MAX_TRANSCRIPT_MESSAGES,
formatDenialStateLog,
recordAllow,
recordFallbackApprove,
shouldFallback,
shouldClassifyAllShellForAutoMode,
finalizeToolResponses,
shouldForceAutoModeReviewForAllow,
Expand Down Expand Up @@ -11909,12 +11911,20 @@ export class Session implements SessionContext {
!forceAutoReviewForAllow &&
!planShellRequiresConfirmation;
if (autoModeAllowed && approvalMode === ApprovalMode.AUTO) {
const actionFingerprint = getAutoModeActionFingerprint(
policyToolName,
toolParams,
this.config.getCwd(),
);
this.config.setAutoModeDenialState(
recordAllow(this.config.getAutoModeDenialState()),
recordAllow(
this.config.getAutoModeDenialState(),
actionFingerprint,
),
);
}
let wasAutoModeManualFallback = false;
let autoModeFallbackMessage: string | undefined;
let autoModeFallback: AutoModeFallbackConfirmation | undefined;

// ── L5: AUTO mode three-layer filter (duplicated from
// coreToolScheduler.ts; ACP routes through this Session path).
Expand All @@ -11926,8 +11936,15 @@ export class Session implements SessionContext {
!requiresUserInteraction &&
shouldRunAutoModeForCall(approvalMode, policyToolName)
) {
const denialState = this.config.getAutoModeDenialState();
const fallback = shouldFallback(denialState);
const actionFingerprint = getAutoModeActionFingerprint(
policyToolName,
toolParams,
this.config.getCwd(),
);
const { denialState, fallback } = prepareAutoModeFallback(
this.config,
actionFingerprint,
);
// `buildClassifierContents` retains only the most recent
// MAX_TRANSCRIPT_MESSAGES messages; ask the chat client for
// exactly that tail rather than triggering a `structuredClone`
Expand Down Expand Up @@ -11961,6 +11978,7 @@ export class Session implements SessionContext {
decision,
this.config,
denialState,
actionFingerprint,
);
await fireSessionPermissionDeniedForAutoMode(
this.config,
Expand Down Expand Up @@ -12002,10 +12020,15 @@ export class Session implements SessionContext {
outcome.reason === 'external_write';

if (
outcome.reason === 'classifier_unavailable' ||
outcome.reason === 'external_write'
outcome.message &&
(outcome.reason === 'classifier_unavailable' ||
outcome.reason === 'external_write' ||
isDenialFallbackReason(outcome.reason))
) {
autoModeFallbackMessage = outcome.message;
autoModeFallback = {
reason: outcome.reason,
message: outcome.message,
};
}

if (wasAutoModeManualFallback) {
Expand Down Expand Up @@ -12110,10 +12133,11 @@ export class Session implements SessionContext {
return confirmationDetailsCancellation;
}

if (autoModeFallbackMessage) {
confirmationDetails = decorateClassifierUnavailableConfirmation(
if (autoModeFallback && confirmationDetails) {
confirmationDetails = decorateAutoModeFallbackConfirmation(
confirmationDetails,
autoModeFallbackMessage,
autoModeFallback.reason,
autoModeFallback.message,
);
}

Expand Down
Loading
Loading