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
135 changes: 131 additions & 4 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ import type { LoadedSettings } from '../../config/settings.js';
import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js';
import { CommandKind } from '../../ui/commands/types.js';

const debugLoggerWarnSpy = vi.hoisted(() => vi.fn());

vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
createDebugLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: debugLoggerWarnSpy,
error: vi.fn(),
}),
};
});

vi.mock('../../nonInteractiveCliCommands.js', () => ({
ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE: [
'init',
Expand Down Expand Up @@ -2203,6 +2219,105 @@ describe('Session', () => {
expect(executeSpy).toHaveBeenCalled();
});

it('resets AUTO denial counters when a permission-request hook approves a denialTracking fallback prompt', async () => {
const hookSpy = vi
.spyOn(core, 'firePermissionRequestHook')
.mockResolvedValue({
hasDecision: true,
shouldAllow: true,
updatedInput: undefined,
denyMessage: undefined,
});
const executeSpy = vi.fn().mockResolvedValue({
llmContent: 'ok',
returnDisplay: 'ok',
});
const onConfirmSpy = vi.fn().mockResolvedValue(undefined);
const setAutoModeDenialState = vi.fn();
const invocation = {
params: { command: 'python -c "print(1)"' },
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
getConfirmationDetails: vi.fn().mockResolvedValue({
type: 'exec',
title: 'Need permission',
command: 'python',
rootCommand: 'python',
onConfirm: onConfirmSpy,
}),
getDescription: vi.fn().mockReturnValue('Run command'),
toolLocations: vi.fn().mockReturnValue([]),
execute: executeSpy,
};
const tool = {
name: core.ToolNames.SHELL,
kind: core.Kind.Execute,
build: vi.fn().mockReturnValue(invocation),
};

mockToolRegistry.getTool.mockReturnValue(tool);
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.AUTO);
mockConfig.getPermissionManager = vi.fn().mockReturnValue(null);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.getMessageBus = vi.fn().mockReturnValue({});
mockConfig.getAutoModeDenialState = vi.fn().mockReturnValue({
consecutiveBlock: 0,
consecutiveUnavailable: 0,
totalBlock: 20,
totalUnavailable: 0,
});
mockConfig.setAutoModeDenialState = setAutoModeDenialState;
(
mockGeminiClient as unknown as {
getHistoryTail: ReturnType<typeof vi.fn>;
}
).getHistoryTail = vi.fn().mockReturnValue([]);
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-auto-fallback-hook-approved',
name: core.ToolNames.SHELL,
args: { command: 'python -c "print(1)"' },
},
],
},
},
]),
);
debugLoggerWarnSpy.mockClear();

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

expect(mockClient.requestPermission).not.toHaveBeenCalled();
await vi.waitFor(() => {
expect(onConfirmSpy).toHaveBeenCalledWith(
core.ToolConfirmationOutcome.ProceedOnce,
);
expect(setAutoModeDenialState).toHaveBeenCalledWith({
consecutiveBlock: 0,
consecutiveUnavailable: 0,
totalBlock: 0,
totalUnavailable: 0,
});
expect(executeSpy).toHaveBeenCalled();
});
expect(debugLoggerWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Auto mode denial counters reset after fallback approval',
),
);
} finally {
hookSpy.mockRestore();
}
});

describe('hooks', () => {
describe('PermissionDenied hook', () => {
it('fires PermissionDenied hooks for AUTO classifier blocks', async () => {
Expand All @@ -2223,7 +2338,11 @@ describe('Session', () => {
stage: 'fast',
durationMs: 20,
},
{ kind: 'blocked', errorMessage: 'blocked' },
{
kind: 'blocked',
errorMessage: 'blocked',
reason: 'classifier_blocked',
},
core.ToolNames.SHELL,
{ command: 'rm -rf /tmp/example' },
'auto-denied-acp',
Expand Down Expand Up @@ -2256,7 +2375,11 @@ describe('Session', () => {
stage: 'fast',
durationMs: 3000,
},
{ kind: 'blocked', errorMessage: 'blocked' },
{
kind: 'blocked',
errorMessage: 'blocked',
reason: 'classifier_unavailable',
},
core.ToolNames.SHELL,
{ command: 'rm -rf /tmp/example' },
'auto-denied-acp',
Expand Down Expand Up @@ -2289,7 +2412,11 @@ describe('Session', () => {
stage: 'fast',
durationMs: 20,
},
{ kind: 'blocked', errorMessage: 'blocked' },
{
kind: 'blocked',
errorMessage: 'blocked',
reason: 'classifier_blocked',
},
core.ToolNames.SHELL,
{ command: 'rm -rf /tmp/example' },
'auto-denied-acp',
Expand All @@ -2316,7 +2443,7 @@ describe('Session', () => {
stage: 'fast',
durationMs: 20,
},
{ kind: 'fallback' },
{ kind: 'fallback', reason: 'safety_check' },
core.ToolNames.SHELL,
{ command: 'rm -rf /tmp/example' },
'auto-denied-acp',
Expand Down
60 changes: 47 additions & 13 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,10 @@ import {
formatStopHookBlockingCapWarning,
applyAutoModeDecision,
evaluateAutoMode,
formatDenialStateLog,
getAutoModePermissionDeniedReason,
isApproveOutcome,
isDenialFallbackReason,
MAX_TRANSCRIPT_MESSAGES,
recordAllow,
recordFallbackApprove,
Expand Down Expand Up @@ -1976,6 +1978,7 @@ export class Session implements SessionContext {
recordAllow(this.config.getAutoModeDenialState()),
);
}
let wasAutoModeDenialFallback = false;

// ── L5: AUTO mode three-layer filter (duplicated from
// coreToolScheduler.ts; ACP routes through this Session path).
Expand All @@ -1984,6 +1987,7 @@ export class Session implements SessionContext {
// existing manual-approval flow below.
if (!autoModeAllowed && shouldRunAutoModeForCall(approvalMode, fc.name)) {
const denialState = this.config.getAutoModeDenialState();
const fallback = shouldFallback(denialState);
// `buildClassifierContents` retains only the most recent
// MAX_TRANSCRIPT_MESSAGES messages; ask the chat client for
// exactly that tail rather than triggering a `structuredClone`
Expand All @@ -2000,7 +2004,7 @@ export class Session implements SessionContext {
messages,
config: this.config,
signal: abortSignal,
skipClassifier: shouldFallback(denialState).fallback,
skipClassifierReason: fallback.fallback ? fallback.reason : undefined,
});

// Apply decision via shared helper — eliminates ~40 lines of
Expand All @@ -2027,9 +2031,20 @@ export class Session implements SessionContext {
autoModeAllowed = true;
break;
case 'blocked':
debugLogger.warn(
`Auto mode blocked (${outcome.reason}): tool=${fc.name}, ` +
formatDenialStateLog(denialState),
);
return earlyErrorResponse(new Error(outcome.errorMessage), fc.name);
case 'fallback':
// Drop through to the manual-approval flow below.
wasAutoModeDenialFallback = isDenialFallbackReason(outcome.reason);
if (wasAutoModeDenialFallback) {
debugLogger.warn(
`Auto mode fallback to manual approval (${outcome.reason}): ` +
formatDenialStateLog(denialState),
);
}
break;
default: {
const _exhaustive: never = outcome;
Expand All @@ -2040,6 +2055,33 @@ export class Session implements SessionContext {

let didRequestPermission = false;
let confirmationDetails: ToolCallConfirmationDetails | undefined;
const recordAutoModeFallbackResolution = (
outcome: ToolConfirmationOutcome,
) => {
// Reset AUTO-mode fallback counters when approval resolves a prompt
// raised because denialTracking forced fallback. This covers both ACP
// requestPermission and PermissionRequest hook approvals.
if (
approvalMode === ApprovalMode.AUTO &&
wasAutoModeDenialFallback &&
isApproveOutcome(outcome)
) {
const before = this.config.getAutoModeDenialState();
const after = recordFallbackApprove(before);
if (after === before) {
debugLogger.warn(
`Auto mode denial counters already clear after fallback approval: ` +
formatDenialStateLog(before),
);
return;
}
debugLogger.warn(
`Auto mode denial counters reset after fallback approval: ` +
`${formatDenialStateLog(before)} -> ${formatDenialStateLog(after)}`,
);
this.config.setAutoModeDenialState(after);
}
};

if (
!autoModeAllowed &&
Expand Down Expand Up @@ -2092,6 +2134,9 @@ export class Session implements SessionContext {
await confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
recordAutoModeFallbackResolution(
ToolConfirmationOutcome.ProceedOnce,
);
} else {
return earlyErrorResponse(
new Error(
Expand Down Expand Up @@ -2159,18 +2204,7 @@ export class Session implements SessionContext {
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);

// Reset the AUTO-mode fallback streak when the user manually
// approves a prompt that was raised because denialTracking forced
// fallback. Without this, a single block-streak permanently
// downgrades the rest of the session to manual approval until the
// mode is toggled. Parallels coreToolScheduler.ts:1705-1717.
// Cancel / abort do NOT reset — treating rejection as a signal
// the classifier was right to block.
if (approvalMode === ApprovalMode.AUTO && isApproveOutcome(outcome)) {
this.config.setAutoModeDenialState(
recordFallbackApprove(this.config.getAutoModeDenialState()),
);
}
recordAutoModeFallbackResolution(outcome);

await confirmationDetails.onConfirm(outcome, {
answers: output.answers,
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setLivePanelFocused(false);
return true;
}
if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
if (
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
setLivePanelFocused(false);
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -951,16 +951,21 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
const selectedAgentIdForActivity =
selectedEntry?.kind === 'agent' ? selectedEntry.agentId : undefined;
useEffect(() => {
if (!dialogOpen || !isDetailMode || !selectedAgentIdForActivity)
return;
if (!dialogOpen || !isDetailMode || !selectedAgentIdForActivity) return;
const registry = config.getBackgroundTaskRegistry();
const onActivity = (entry: AgentTask) => {
if (entry.agentId !== selectedAgentIdForActivity) return;
setActivityTick((n) => n + 1);
};
registry.setActivityChangeCallback(onActivity);
return () => registry.setActivityChangeCallback(undefined);
}, [dialogOpen, dialogMode, isDetailMode, config, selectedAgentIdForActivity]);
}, [
dialogOpen,
dialogMode,
isDetailMode,
config,
selectedAgentIdForActivity,
]);

// Wall-clock tick for the running agent's duration. Activity callbacks
// fire when tools run, but duration needs to advance even when the agent
Expand Down Expand Up @@ -1021,7 +1026,14 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
) {
exitDetail();
}
}, [dialogOpen, dialogMode, isDetailMode, selectedEntryId, selectedStatus, exitDetail]);
}, [
dialogOpen,
dialogMode,
isDetailMode,
selectedEntryId,
selectedStatus,
exitDetail,
]);

// Encapsulates the cancel flow with the foreground confirm-step.
// Foreground entries: first `x` arms; second `x` confirms. Background
Expand Down
Loading
Loading