qwen-code/packages/cli/src/acp-integration/session/Session.test.ts
顾盼 2710bdec0d
feat(cli): Phase 2 — slash command multi-mode expansion, ACP fixes, and UX improvements (#3377)
* refactor(cli): replace slash command whitelist with capability-based filtering (Phase 1)

## Summary

Replace the hardcoded ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE whitelist with a
unified, capability-based command metadata model. This is Phase 1 of the slash
command architecture refactor described in docs/design/slash-command/.

## Key changes

### New types (types.ts)
- Add ExecutionMode ('interactive' | 'non_interactive' | 'acp')
- Add CommandSource ('builtin-command' | 'bundled-skill' | 'skill-dir-command' |
  'plugin-command' | 'mcp-prompt')
- Add CommandType ('prompt' | 'local' | 'local-jsx')
- Extend SlashCommand interface with: source, sourceLabel, commandType,
  supportedModes, userInvocable, modelInvocable, argumentHint, whenToUse,
  examples (all optional, backward-compatible)

### New module (commandUtils.ts + commandUtils.test.ts)
- getEffectiveSupportedModes(): 3-priority inference
  (explicit supportedModes > commandType > CommandKind fallback)
- filterCommandsForMode(): replaces filterCommandsForNonInteractive()
- 18 unit tests

### Whitelist removal (nonInteractiveCliCommands.ts)
- Remove ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE constant
- Remove filterCommandsForNonInteractive() function
- Replace with CommandService.getCommandsForMode(mode)

### CommandService enhancements (CommandService.ts)
- Add getCommandsForMode(mode: ExecutionMode): filters by mode, excludes hidden
- Add getModelInvocableCommands(): reserved for Phase 3 model tool-call use

### Built-in command annotations (41 files)
Annotate every built-in command with commandType:
- commandType='local' + supportedModes all-modes: btw, bug, compress, context,
  init, summary (replaces the 6-command whitelist)
- commandType='local' interactive-only: export, memory, plan, insight
- commandType='local-jsx' interactive-only: all remaining ~31 commands

### Loader metadata injection (4 files)
Each loader stamps source/sourceLabel/commandType/modelInvocable on every
command it emits:
- BuiltinCommandLoader: source='builtin-command', modelInvocable=false
- BundledSkillLoader: source='bundled-skill', commandType='prompt',
  modelInvocable=true
- command-factory (FileCommandLoader): source per extension/user origin,
  commandType='prompt', modelInvocable=!extensionName
- McpPromptLoader: source='mcp-prompt', commandType='prompt', modelInvocable=true

### Bug fix
MCP_PROMPT commands were incorrectly excluded from non-interactive/ACP modes by
the old whitelist logic. commandType='prompt' now correctly allows them in all
modes.

### Session.ts / nonInteractiveHelpers.ts
- ACP session calls getAvailableCommands with explicit 'acp' mode
- Remove allowedBuiltinCommandNames parameter from buildSystemMessage() —
  capability filtering is now self-contained in CommandService

* fix test ci

* feat(cli): Phase 2 slash command expansion + ACP fixes + UX improvements

Phase 2.1 - Command mode expansion:
- Extend 13 built-in commands to support non_interactive/acp modes
- A class: export, plan, statusline - supportedModes only
- A+ class: language, copy, restore - add non-interactive branches
- A' class: model, approvalMode - handle dialog paths in non-interactive
- B class: about, stats, insight, docs, clear - full non-interactive branches
- context: format output as readable Markdown instead of raw JSON
- export: use HTML as default format when no subcommand given

Phase 2.2 - SkillTool integration:
- SkillTool now consumes CommandService.getModelInvocableCommands()

Phase 2.3 - Mid-input slash ghost text:
- Replace mid-input dropdown completion with inline ghost text
- Match Claude Code behavior: gray dimmed completion hint in input box
- Tab accepts the ghost text completion
- Add findMidInputSlashCommand() and getBestSlashCommandMatch() utilities

ACP session bug fixes:
- Fix executionMode undefined in interactive mode (slashCommandProcessor)
- Fix slash command output not visible in Zed (use emitAgentMessage)
- Fix newline rendering in Zed (Markdown hard line-break)
- Fix history replay merging consecutive user messages (recordSlashCommand)
- Fix /clear not clearing model context (dynamic chat reference)

* feat: inline complete only for modelInvocable

* fix memory command

* fix: pass 'non_interactive' mode explicitly to getAvailableCommands

- Fix critical bug in nonInteractiveHelpers.ts: loadSlashCommandNames was
  calling getAvailableCommands without specifying mode, causing it to default
  to 'acp' instead of 'non_interactive'. Commands with supportedModes that
  include 'non_interactive' but not 'acp' would be silently excluded.
- Apply the same fix in systemController.ts for the same reason.
- Update test mock to delegate filtering to production filterCommandsForMode()
  instead of duplicating the logic inline, preventing divergence.

Fixes review comments by wenshao and tanzhenxin on PR #3283.

* fix: resolve TypeScript type error in nonInteractiveHelpers.test.ts

* fix test ci

* fix mcp prompt in skill manager

* revert pr#3345

* fix test ci

* feat(cli): adapt /insight for non_interactive mode with message return

- non_interactive: run generateStaticInsight() synchronously with no-op
  progress callback, return { type: 'message' } with output path
- acp: keep existing stream_messages path with progress streaming
- interactive: unchanged

Add tests for non_interactive success and error paths.

Update phase2-technical-design.md and roadmap.md to reflect the
three-way mode split and clarify that MCP prompts do not need
modelInvocable (they are called via native MCP tool call mechanism).

* fix(cli): ghost text only shown when cursor is at end of slash token

Use strict equality (!==) instead of > in findMidInputSlashCommand so that
ghost text is only computed and Tab-accepted when the cursor sits exactly at
the trailing edge of the partial command token.

Previously, with the cursor inside an already-typed token (e.g. /re|view),
the ghost text suffix would still be shown and pressing Tab would insert it
at the cursor position, producing a duplicated tail. Using strict equality
makes ghost text disappear as soon as the cursor moves inside the token.

Add unit tests for findMidInputSlashCommand covering cursor-at-end,
cursor-inside-token, cursor-past-token, start-of-line, and
no-space-before-slash cases.

* fix(cli): support /model <model-id> in non-interactive and ACP modes

Previously, /model <model-id> (without --fast) fell through to the
non-interactive branch that only returned the current model info and
incorrectly told users to use --fast. Now:

- /model <model-id>  → sets the main model via settings + config.setModel()
- /model             → shows current model with correct usage hint
- /model --fast <id> → unchanged (sets fast model)

Fixes the inconsistency flagged in PR review: the help text said to use
'/model <model-id>' but the command returned a dialog action which is
unsupported in non-interactive mode.

* fix(cli): declare supportedModes on doctorCommand to enable non-interactive and ACP

The command's action already had non-interactive handling (returns a JSON
message with check results), but without supportedModes declared the
BUILT_IN fallback restricted it to interactive-only so it was never
registered in non_interactive or acp sessions.

* feat(skills): add SkillCommandLoader for user/project/extension skills as slash commands

- New SkillCommandLoader loads user, project, and extension level SKILL.md
  files as slash commands (previously only bundled skills were slash-invocable)
- Extension skills follow plugin-command rules: modelInvocable only when
  description or whenToUse is present
- User/project skills are always modelInvocable (matching bundled behavior)
- skill-manager now injects extensionName when loading extension-level skills
- Add when_to_use and disable-model-invocation frontmatter support to SKILL.md
  and .md command files (SkillConfig, markdown-command-parser, command-factory,
  BundledSkillLoader, FileCommandLoader)
- SkillTool filters out skills with disableModelInvocation and includes
  whenToUse in the skill description shown to the model
- 16 unit tests for SkillCommandLoader covering all cases

* docs: update phase2 design doc to reflect final decisions on plan/statusline/copy/restore

These four commands are intentionally kept as interactive-only by design:
- /plan and /statusline: tightly coupled with interactive multi-turn UI
- /copy and /restore: clipboard and snapshot restore are inherently interactive

Update design doc classification table, section 4.2, 4.3, 5.2, 5.3,
file change summary, test requirements, behavior analysis table,
and implementation batch descriptions to reflect this decision.

* feat(cli): re-implement slashCommands.disabled denylist based on current refactored code

Adapts the feature originally introduced in pr#3445 to the current
CommandService / Phase-2 refactored code.

Sources (merged, de-duplicated, case-insensitive):
  - settings key slashCommands.disabled (string[], UNION merge)
  - --disabled-slash-commands CLI flag (comma-separated or repeated)
  - QWEN_DISABLED_SLASH_COMMANDS environment variable

Enforcement points:
  - CommandService.create() accepts optional disabledNames: ReadonlySet<string>
    and removes matching commands post-rename, so disabled commands never appear
    in autocomplete, mid-input ghost text, or model-invocable commands list.
  - slashCommandProcessor (interactive TUI) passes the denylist to
    CommandService.create so disabled commands are absent from dropdown/ghost text.
  - nonInteractiveCliCommands.handleSlashCommand() keeps allCommands unfiltered
    to distinguish disabled vs unknown; disabled commands return unsupported with
    a "disabled by the current configuration" reason (not no_command).
  - getAvailableCommands() (ACP) passes the denylist to CommandService.create.

Config plumbing:
  - core/Config: ConfigParameters.disabledSlashCommands + getDisabledSlashCommands()
  - cli/config: CliArgs.disabledSlashCommands + yargs option + loadCliConfig merge
  - settingsSchema: slashCommands.disabled (MergeStrategy.UNION)
  - settings.schema.json: regenerated

Tests: 28 pass (CommandService x4, nonInteractiveCliCommands x3 new cases)

* feat(cli): complete slashCommands.disabled coverage from pr#3445

Fill in the three items that were missing from the initial re-implementation:

- packages/cli/src/config/settings.test.ts: add UNION-merge test for
  slashCommands.disabled across user and workspace scopes
- packages/cli/src/nonInteractiveCli.test.ts: add getDisabledSlashCommands
  mock to the shared mockConfig fixture
- docs/users/configuration/settings.md: add slashCommands section (table +
  example + note) and --disabled-slash-commands row in the CLI args table

* fix(cli): match disabled slash commands by alias as well as primary name

The denylist previously only checked cmd.name (the primary/canonical name),
so disabling a command by its alias (e.g. 'about' for the 'status' command)
had no effect. Fix both CommandService.create() and the isDisabled() helper
in nonInteractiveCliCommands.ts to also check altNames.

Also improve the user-facing error message to show the token the user actually
typed (e.g. /about) instead of always showing the primary name (/status).
2026-04-22 19:12:44 +08:00

1187 lines
39 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { Session } from './Session.js';
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
import { ApprovalMode, AuthType } from '@qwen-code/qwen-code-core';
import * as core from '@qwen-code/qwen-code-core';
import type {
AgentSideConnection,
PromptRequest,
} from '@agentclientprotocol/sdk';
import type { LoadedSettings } from '../../config/settings.js';
import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js';
vi.mock('../../nonInteractiveCliCommands.js', () => ({
ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE: [
'init',
'summary',
'compress',
'bug',
],
getAvailableCommands: vi.fn(),
handleSlashCommand: vi.fn(),
}));
// Helper to create empty async generator (avoids memory leak from inline generators)
function createEmptyStream() {
return (async function* () {})();
}
// Helper to create async generator with chunks (avoids memory leak)
function createStreamWithChunks(
chunks: Array<{ type: unknown; value: unknown }>,
) {
return (async function* () {
for (const chunk of chunks) {
yield chunk;
}
})();
}
describe('Session', () => {
let mockChat: GeminiChat;
let mockConfig: Config;
let mockClient: AgentSideConnection;
let mockSettings: LoadedSettings;
let session: Session;
let currentModel: string;
let currentAuthType: AuthType;
let switchModelSpy: ReturnType<typeof vi.fn>;
let getAvailableCommandsSpy: ReturnType<typeof vi.fn>;
let mockToolRegistry: {
getTool: ReturnType<typeof vi.fn>;
ensureTool: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
currentModel = 'qwen3-code-plus';
currentAuthType = AuthType.USE_OPENAI;
switchModelSpy = vi
.fn()
.mockImplementation(async (authType: AuthType, modelId: string) => {
currentAuthType = authType;
currentModel = modelId;
});
mockChat = {
sendMessageStream: vi.fn(),
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
} as unknown as GeminiChat;
mockToolRegistry = {
getTool: vi.fn(),
// #executePrompt → #buildInitialSystemReminders calls
// getToolRegistry().ensureTool(ToolNames.AGENT) on every session.prompt(),
// so the default mock must provide it (#1151 / #3479).
ensureTool: vi.fn().mockResolvedValue(true),
};
const fileService = { shouldGitIgnoreFile: vi.fn().mockReturnValue(false) };
mockConfig = {
setApprovalMode: vi.fn(),
// #buildInitialSystemReminders branches on ApprovalMode.PLAN on every
// session.prompt(), so the default must be defined. Individual tests
// that care override via `mockConfig.getApprovalMode = vi.fn()...`.
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
switchModel: switchModelSpy,
getModel: vi.fn().mockImplementation(() => currentModel),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getWorkingDir: vi.fn().mockReturnValue(process.cwd()),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue(undefined),
getChatRecordingService: vi.fn().mockReturnValue({
recordUserMessage: vi.fn(),
recordUiTelemetryEvent: vi.fn(),
recordToolResult: vi.fn(),
}),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
// #buildInitialSystemReminders iterates listSubagents() on every
// session.prompt(). Default to an empty list so tests that don't
// exercise subagent reminders don't need to stub it (#1151 / #3479).
getSubagentManager: vi.fn().mockReturnValue({
listSubagents: vi.fn().mockResolvedValue([]),
}),
getFileService: vi.fn().mockReturnValue(fileService),
getFileFilteringRespectGitIgnore: vi.fn().mockReturnValue(true),
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
getTargetDir: vi.fn().mockReturnValue(process.cwd()),
getDebugMode: vi.fn().mockReturnValue(false),
getAuthType: vi.fn().mockImplementation(() => currentAuthType),
isCronEnabled: vi.fn().mockReturnValue(false),
getGeminiClient: vi
.fn()
.mockReturnValue({ getChat: vi.fn().mockReturnValue(mockChat) }),
} as unknown as Config;
mockClient = {
sessionUpdate: vi.fn().mockResolvedValue(undefined),
requestPermission: vi.fn().mockResolvedValue({
outcome: { outcome: 'selected', optionId: 'proceed_once' },
}),
extNotification: vi.fn().mockResolvedValue(undefined),
} as unknown as AgentSideConnection;
mockSettings = {
merged: {},
} as LoadedSettings;
getAvailableCommandsSpy = vi.mocked(nonInteractiveCliCommands)
.getAvailableCommands as unknown as ReturnType<typeof vi.fn>;
getAvailableCommandsSpy.mockResolvedValue([]);
session = new Session(
'test-session-id',
mockConfig,
mockClient,
mockSettings,
);
});
afterEach(() => {
// Reset global runtime base dir state to prevent state leakage between tests
core.Storage.setRuntimeBaseDir(null);
// Clear session reference to allow garbage collection
session = undefined as unknown as Session;
mockChat = undefined as unknown as GeminiChat;
mockConfig = undefined as unknown as Config;
mockClient = undefined as unknown as AgentSideConnection;
mockSettings = undefined as unknown as LoadedSettings;
mockToolRegistry = undefined as unknown as typeof mockToolRegistry;
vi.restoreAllMocks();
vi.clearAllTimers();
});
describe('setMode', () => {
it.each([
['plan', ApprovalMode.PLAN],
['default', ApprovalMode.DEFAULT],
['auto-edit', ApprovalMode.AUTO_EDIT],
['yolo', ApprovalMode.YOLO],
] as const)('maps %s mode', async (modeId, expected) => {
await session.setMode({
sessionId: 'test-session-id',
modeId,
});
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(expected);
});
});
describe('setModel', () => {
it('sets model via config and returns current model', async () => {
const requested = `qwen3-coder-plus(${AuthType.USE_OPENAI})`;
await session.setModel({
sessionId: 'test-session-id',
modelId: ` ${requested} `,
});
expect(mockConfig.switchModel).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'qwen3-coder-plus',
undefined,
);
});
it('rejects empty/whitespace model IDs', async () => {
await expect(
session.setModel({
sessionId: 'test-session-id',
modelId: ' ',
}),
).rejects.toThrow('Invalid params');
expect(mockConfig.switchModel).not.toHaveBeenCalled();
});
it('propagates errors from config.switchModel', async () => {
const configError = new Error('Invalid model');
switchModelSpy.mockRejectedValueOnce(configError);
await expect(
session.setModel({
sessionId: 'test-session-id',
modelId: `invalid-model(${AuthType.USE_OPENAI})`,
}),
).rejects.toThrow('Invalid model');
});
});
describe('sendAvailableCommandsUpdate', () => {
it('sends available_commands_update from getAvailableCommands()', async () => {
getAvailableCommandsSpy.mockResolvedValueOnce([
{
name: 'init',
description: 'Initialize project context',
},
]);
await session.sendAvailableCommandsUpdate();
expect(getAvailableCommandsSpy).toHaveBeenCalledWith(
mockConfig,
expect.any(AbortSignal),
'acp',
);
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
sessionId: 'test-session-id',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{
name: 'init',
description: 'Initialize project context',
input: null,
},
],
},
});
});
it('swallows errors and does not throw', async () => {
getAvailableCommandsSpy.mockRejectedValueOnce(
new Error('Command discovery failed'),
);
await expect(
session.sendAvailableCommandsUpdate(),
).resolves.toBeUndefined();
expect(mockClient.sessionUpdate).not.toHaveBeenCalled();
});
});
describe('prompt', () => {
it('passes resolved paths to read_many_files tool', async () => {
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-acp-session-'),
);
const fileName = 'README.md';
const filePath = path.join(tempDir, fileName);
const readManyFilesSpy = vi
.spyOn(core, 'readManyFiles')
.mockResolvedValue({
contentParts: 'file content',
files: [],
});
try {
await fs.writeFile(filePath, '# Test\n', 'utf8');
mockConfig.getTargetDir = vi.fn().mockReturnValue(tempDir);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());
const promptRequest: PromptRequest = {
sessionId: 'test-session-id',
prompt: [
{ type: 'text', text: 'Check this file' },
{
type: 'resource_link',
name: fileName,
uri: `file://${fileName}`,
},
],
};
await session.prompt(promptRequest);
expect(readManyFilesSpy).toHaveBeenCalledWith(mockConfig, {
paths: [fileName],
signal: expect.any(AbortSignal),
});
} finally {
readManyFilesSpy.mockRestore();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('runs prompt inside runtime output dir context', async () => {
const runtimeDir = path.resolve('runtime', 'from-settings');
core.Storage.setRuntimeBaseDir(runtimeDir);
session = new Session(
'test-session-id',
mockConfig,
mockClient,
mockSettings,
);
const runWithRuntimeBaseDirSpy = vi.spyOn(
core.Storage,
'runWithRuntimeBaseDir',
);
try {
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());
const promptRequest: PromptRequest = {
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'hello' }],
};
await session.prompt(promptRequest);
expect(runWithRuntimeBaseDirSpy).toHaveBeenCalledWith(
runtimeDir,
process.cwd(),
expect.any(Function),
);
} finally {
runWithRuntimeBaseDirSpy.mockRestore();
}
});
it('hides allow-always options when confirmation already forbids them', async () => {
const executeSpy = vi.fn().mockResolvedValue({
llmContent: 'ok',
returnDisplay: 'ok',
});
const onConfirmSpy = vi.fn().mockResolvedValue(undefined);
const invocation = {
params: { path: '/tmp/file.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
getConfirmationDetails: vi.fn().mockResolvedValue({
type: 'info',
title: 'Need permission',
prompt: 'Allow?',
hideAlwaysAllow: true,
onConfirm: onConfirmSpy,
}),
getDescription: vi.fn().mockReturnValue('Inspect file'),
toolLocations: vi.fn().mockReturnValue([]),
execute: executeSpy,
};
const tool = {
name: 'read_file',
kind: core.Kind.Read,
build: vi.fn().mockReturnValue(invocation),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.DEFAULT);
mockConfig.getPermissionManager = vi.fn().mockReturnValue(null);
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-1',
name: 'read_file',
args: { path: '/tmp/file.txt' },
},
],
},
},
]),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'run tool' }],
});
expect(mockClient.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
options: [
expect.objectContaining({ kind: 'allow_once' }),
expect.objectContaining({ kind: 'reject_once' }),
],
}),
);
const options = (mockClient.requestPermission as ReturnType<typeof vi.fn>)
.mock.calls[0][0].options as Array<{ kind: string }>;
expect(options.some((option) => option.kind === 'allow_always')).toBe(
false,
);
});
it('allows info confirmation tools in plan mode', async () => {
const executeSpy = vi.fn().mockResolvedValue({
llmContent: 'ok',
returnDisplay: 'ok',
});
const onConfirmSpy = vi.fn().mockResolvedValue(undefined);
const invocation = {
params: {
url: 'https://example.com/docs',
prompt: 'Summarize the docs',
},
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
getConfirmationDetails: vi.fn().mockResolvedValue({
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'Allow fetching docs?',
urls: ['https://example.com/docs'],
onConfirm: onConfirmSpy,
}),
getDescription: vi.fn().mockReturnValue('Fetch docs'),
toolLocations: vi.fn().mockReturnValue([]),
execute: executeSpy,
};
const tool = {
name: 'web_fetch',
kind: core.Kind.Fetch,
build: vi.fn().mockReturnValue(invocation),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.PLAN);
mockConfig.getPermissionManager = vi.fn().mockReturnValue(null);
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-info-plan',
name: 'web_fetch',
args: {
url: 'https://example.com/docs',
prompt: 'Summarize the docs',
},
},
],
},
},
]),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'research the docs first' }],
});
expect(mockClient.requestPermission).toHaveBeenCalled();
expect(onConfirmSpy).toHaveBeenCalledWith(
core.ToolConfirmationOutcome.ProceedOnce,
{ answers: undefined },
);
expect(executeSpy).toHaveBeenCalled();
});
it('returns permission error for disabled tools (L1 isToolEnabled check)', async () => {
const executeSpy = vi.fn();
const invocation = {
params: { path: '/tmp/file.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
getConfirmationDetails: vi.fn().mockResolvedValue({
type: 'info',
title: 'Need permission',
prompt: 'Allow?',
onConfirm: vi.fn(),
}),
getDescription: vi.fn().mockReturnValue('Write file'),
toolLocations: vi.fn().mockReturnValue([]),
execute: executeSpy,
};
const tool = {
name: 'write_file',
kind: core.Kind.Edit,
build: vi.fn().mockReturnValue(invocation),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.DEFAULT);
// Mock a PermissionManager that denies the tool
mockConfig.getPermissionManager = vi.fn().mockReturnValue({
isToolEnabled: vi.fn().mockResolvedValue(false),
});
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-denied',
name: 'write_file',
args: { path: '/tmp/file.txt' },
},
],
},
},
]),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'write something' }],
});
// Tool should NOT have been executed
expect(executeSpy).not.toHaveBeenCalled();
// No permission dialog should have been opened
expect(mockClient.requestPermission).not.toHaveBeenCalled();
});
it('respects permission-request hook allow decisions without opening ACP permission dialog', async () => {
const hookSpy = vi
.spyOn(core, 'firePermissionRequestHook')
.mockResolvedValue({
hasDecision: true,
shouldAllow: true,
updatedInput: { path: '/tmp/updated.txt' },
denyMessage: undefined,
});
const executeSpy = vi.fn().mockResolvedValue({
llmContent: 'ok',
returnDisplay: 'ok',
});
const onConfirmSpy = vi.fn().mockResolvedValue(undefined);
const invocation = {
params: { path: '/tmp/original.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
getConfirmationDetails: vi.fn().mockResolvedValue({
type: 'info',
title: 'Need permission',
prompt: 'Allow?',
onConfirm: onConfirmSpy,
}),
getDescription: vi.fn().mockReturnValue('Inspect file'),
toolLocations: vi.fn().mockReturnValue([]),
execute: executeSpy,
};
const tool = {
name: 'read_file',
kind: core.Kind.Read,
build: vi.fn().mockReturnValue(invocation),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.DEFAULT);
mockConfig.getPermissionManager = vi.fn().mockReturnValue(null);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.getMessageBus = vi.fn().mockReturnValue({});
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-2',
name: 'read_file',
args: { path: '/tmp/original.txt' },
},
],
},
},
]),
);
try {
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'run tool' }],
});
} finally {
hookSpy.mockRestore();
}
expect(mockClient.requestPermission).not.toHaveBeenCalled();
expect(onConfirmSpy).toHaveBeenCalledWith(
core.ToolConfirmationOutcome.ProceedOnce,
);
expect(invocation.params).toEqual({ path: '/tmp/updated.txt' });
expect(executeSpy).toHaveBeenCalled();
});
describe('hooks', () => {
describe('UserPromptSubmit hook', () => {
it('fires UserPromptSubmit hook before sending prompt', async () => {
const messageBus = {
request: vi.fn().mockResolvedValue({
success: true,
output: {},
}),
};
mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true);
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
candidates: [{ content: { parts: [{ text: 'response' }] } }],
},
},
]),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'hello' }],
});
expect(messageBus.request).toHaveBeenCalledWith(
expect.objectContaining({
eventName: 'UserPromptSubmit',
input: { prompt: 'hello' },
}),
expect.anything(),
);
});
it('blocks prompt when UserPromptSubmit hook returns blocking decision', async () => {
const messageBus = {
request: vi.fn().mockResolvedValue({
success: true,
output: { decision: 'block', reason: 'Blocked by hook' },
}),
};
mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true);
mockChat.sendMessageStream = vi.fn();
const result = await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'blocked prompt' }],
});
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
expect(result.stopReason).toBe('end_turn');
});
});
describe('Stop hook', () => {
it('fires Stop hook after model response completes', async () => {
const messageBus = {
request: vi.fn().mockResolvedValue({
success: true,
output: {},
}),
};
mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true);
mockChat.getHistory = vi
.fn()
.mockReturnValue([
{ role: 'model', parts: [{ text: 'response text' }] },
]);
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
candidates: [{ content: { parts: [{ text: 'response' }] } }],
},
},
]),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'hello' }],
});
expect(messageBus.request).toHaveBeenCalledWith(
expect.objectContaining({
eventName: 'Stop',
input: expect.objectContaining({
stop_hook_active: true,
last_assistant_message: 'response text',
}),
}),
expect.anything(),
);
});
});
describe('PreToolUse hook', () => {
it('fires PreToolUse hook before tool execution', async () => {
const messageBus = {
request: vi.fn().mockResolvedValue({
success: true,
output: {},
}),
};
mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.YOLO);
const executeSpy = vi.fn().mockResolvedValue({
llmContent: 'result',
returnDisplay: 'done',
});
const tool = {
name: 'read_file',
kind: core.Kind.Read,
build: vi.fn().mockReturnValue({
params: { path: '/tmp/test.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
execute: executeSpy,
}),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-1',
name: 'read_file',
args: { path: '/tmp/test.txt' },
},
],
},
},
]),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'read the file' }],
});
expect(messageBus.request).toHaveBeenCalledWith(
expect.objectContaining({
eventName: 'PreToolUse',
input: expect.objectContaining({
tool_name: 'read_file',
tool_input: { path: '/tmp/test.txt' },
}),
}),
expect.anything(),
);
});
it('blocks tool execution when PreToolUse hook returns blocking decision', async () => {
const messageBus = {
request: vi.fn().mockResolvedValue({
success: true,
output: { decision: 'deny', reason: 'Tool blocked by hook' },
}),
};
mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.YOLO);
const executeSpy = vi.fn();
const tool = {
name: 'read_file',
kind: core.Kind.Read,
build: vi.fn().mockReturnValue({
params: { path: '/tmp/test.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
execute: executeSpy,
}),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-1',
name: 'read_file',
args: { path: '/tmp/test.txt' },
},
],
},
},
]),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'read the file' }],
});
expect(executeSpy).not.toHaveBeenCalled();
});
});
describe('PostToolUse hook', () => {
it('fires PostToolUse hook after successful tool execution', async () => {
const messageBus = {
request: vi.fn().mockResolvedValue({
success: true,
output: {},
}),
};
mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.YOLO);
const executeSpy = vi.fn().mockResolvedValue({
llmContent: 'file contents',
returnDisplay: 'success',
});
const tool = {
name: 'read_file',
kind: core.Kind.Read,
build: vi.fn().mockReturnValue({
params: { path: '/tmp/test.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
execute: executeSpy,
}),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-1',
name: 'read_file',
args: { path: '/tmp/test.txt' },
},
],
},
},
]),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'read the file' }],
});
expect(messageBus.request).toHaveBeenCalledWith(
expect.objectContaining({
eventName: 'PostToolUse',
input: expect.objectContaining({
tool_name: 'read_file',
tool_response: expect.objectContaining({
llmContent: 'file contents',
returnDisplay: 'success',
}),
}),
}),
expect.anything(),
);
});
it('stops execution when PostToolUse hook returns shouldStop', async () => {
const messageBus = {
request: vi.fn().mockResolvedValue({
success: true,
output: { shouldStop: true, reason: 'Stopping per hook request' },
}),
};
mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.YOLO);
const executeSpy = vi.fn().mockResolvedValue({
llmContent: 'file contents',
returnDisplay: 'success',
});
const tool = {
name: 'read_file',
kind: core.Kind.Read,
build: vi.fn().mockReturnValue({
params: { path: '/tmp/test.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
execute: executeSpy,
}),
};
mockToolRegistry.getTool.mockReturnValue(tool);
// Only one call expected since shouldStop prevents continuation
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-1',
name: 'read_file',
args: { path: '/tmp/test.txt' },
},
],
},
},
]),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'read the file' }],
});
// Tool should have been executed
expect(executeSpy).toHaveBeenCalled();
// PostToolUse hook should have been called
expect(messageBus.request).toHaveBeenCalledWith(
expect.objectContaining({
eventName: 'PostToolUse',
}),
expect.anything(),
);
});
});
describe('PostToolUseFailure hook', () => {
it('fires PostToolUseFailure hook when tool execution fails', async () => {
const messageBus = {
request: vi.fn().mockResolvedValue({
success: true,
output: {},
}),
};
mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.YOLO);
const executeSpy = vi
.fn()
.mockRejectedValue(new Error('Tool failed'));
const tool = {
name: 'read_file',
kind: core.Kind.Read,
build: vi.fn().mockReturnValue({
params: { path: '/tmp/test.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
execute: executeSpy,
}),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-1',
name: 'read_file',
args: { path: '/tmp/test.txt' },
},
],
},
},
]),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'read the file' }],
});
expect(messageBus.request).toHaveBeenCalledWith(
expect.objectContaining({
eventName: 'PostToolUseFailure',
input: expect.objectContaining({
tool_name: 'read_file',
error: 'Tool failed',
}),
}),
expect.anything(),
);
});
});
describe('StopFailure hook', () => {
it('fires StopFailure hook when API error occurs during sendMessageStream', async () => {
const mockFireStopFailureEvent = vi.fn().mockResolvedValue({
success: true,
});
mockConfig.getHookSystem = vi.fn().mockReturnValue({
fireStopFailureEvent: mockFireStopFailureEvent,
});
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true);
// Simulate API error (rate limit)
const apiError = new Error('Rate limit exceeded') as Error & {
status: number;
};
apiError.status = 429;
mockChat.sendMessageStream = vi.fn().mockImplementation(async () => {
throw apiError;
});
await expect(
session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'hello' }],
}),
).rejects.toThrow();
// StopFailure hook should be called with rate_limit error type
expect(mockFireStopFailureEvent).toHaveBeenCalledWith(
'rate_limit',
'Rate limit exceeded',
);
});
it('does not fire StopFailure hook when hooks are disabled', async () => {
const mockFireStopFailureEvent = vi.fn();
mockConfig.getHookSystem = vi.fn().mockReturnValue({
fireStopFailureEvent: mockFireStopFailureEvent,
});
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(true);
const apiError = new Error('Rate limit exceeded') as Error & {
status: number;
};
apiError.status = 429;
mockChat.sendMessageStream = vi.fn().mockImplementation(async () => {
throw apiError;
});
await expect(
session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'hello' }],
}),
).rejects.toThrow();
expect(mockFireStopFailureEvent).not.toHaveBeenCalled();
});
});
});
describe('system reminders', () => {
// Captures the `message` parts fed into chat.sendMessageStream on the
// first turn so individual tests can assert what the model saw.
const captureFirstTurnMessage = () => {
const capture: { parts: Array<{ text?: string }> } = { parts: [] };
(mockChat.sendMessageStream as ReturnType<typeof vi.fn>) = vi
.fn()
.mockImplementation(async (_model, req) => {
capture.parts = req.message ?? [];
return createEmptyStream();
});
return capture;
};
const stubEmptySubagents = () => {
(mockConfig as unknown as Record<string, unknown>)[
'getSubagentManager'
] = vi.fn().mockReturnValue({
listSubagents: vi.fn().mockResolvedValue([]),
});
// ensureTool is called on the result of getToolRegistry(); add it.
(
mockToolRegistry as unknown as { ensureTool: () => Promise<boolean> }
).ensureTool = vi.fn().mockResolvedValue(true);
};
it('prepends plan-mode reminder when approval mode is PLAN (#1151)', async () => {
stubEmptySubagents();
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.PLAN);
const capture = captureFirstTurnMessage();
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'research this' }],
});
const reminderPart = capture.parts.find(
(p) => p.text && p.text.includes('Plan mode is active'),
);
expect(reminderPart).toBeTruthy();
expect(reminderPart!.text).toContain('exit_plan_mode');
// Reminder comes before the user text, matching client.ts ordering.
const reminderIdx = capture.parts.indexOf(reminderPart!);
const userIdx = capture.parts.findIndex(
(p) => p.text === 'research this',
);
expect(reminderIdx).toBeLessThan(userIdx);
});
it('does not prepend plan-mode reminder in default approval mode', async () => {
stubEmptySubagents();
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.DEFAULT);
const capture = captureFirstTurnMessage();
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'hi' }],
});
const hasPlanReminder = capture.parts.some(
(p) => p.text && p.text.includes('Plan mode is active'),
);
expect(hasPlanReminder).toBe(false);
});
it('prepends subagent reminder when user-level subagents exist', async () => {
(mockConfig as unknown as Record<string, unknown>)[
'getSubagentManager'
] = vi.fn().mockReturnValue({
listSubagents: vi.fn().mockResolvedValue([
{ name: 'researcher', level: 'user' },
{ name: 'planner', level: 'project' },
// builtin entries are filtered out, matching client.ts:853.
{ name: 'builtin-helper', level: 'builtin' },
]),
});
(
mockToolRegistry as unknown as { ensureTool: () => Promise<boolean> }
).ensureTool = vi.fn().mockResolvedValue(true);
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.DEFAULT);
const capture = captureFirstTurnMessage();
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'hi' }],
});
const reminder = capture.parts.find(
(p) =>
p.text &&
p.text.includes('researcher') &&
p.text.includes('planner'),
);
expect(reminder).toBeTruthy();
expect(reminder!.text).not.toContain('builtin-helper');
});
});
});
});