qwen-code/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
顾盼 9e2f63a1ca
feat(memory): managed auto-memory and auto-dream system (#3087)
* docs: add auto-memory implementation log

* feat(core): add managed auto-memory storage scaffold

* feat(core): load managed auto-memory index

* feat(core): add managed auto-memory recall

* feat(core): add managed auto-memory extraction

* feat(cli): add managed auto-memory dream commands

* feat(core): add auxiliary side-query foundation

* feat(memory): add model-driven recall selection

* feat(memory): add model-driven extraction planner

* feat(core): add background task runtime foundation

* feat(memory): schedule auto dream in background

* feat(core): add background agent runner foundation

* feat(memory): add extraction agent planner

* feat(core): add dream agent planner

* feat(core): rebuild managed memory index

* feat(memory): add governance status commands

* feat(memory): add managed forget flow

* feat(core): harden background agent planning

* feat(memory): complete managed parity closure

* test(memory): add managed lifecycle integration coverage

* feat: same to cc

* feat(memory-ui): add memory saved notification and memory count badge

Feature 3 - Memory Saved Notification:
- Add HistoryItemMemorySaved type to types.ts
- Create MemorySavedMessage component for rendering '● Saved/Updated N memories'
- In useGeminiStream: detect in-turn memory writes via mapToDisplay's
  memoryWriteCount field and emit 'memory_saved' history item after turn
- In client.ts: capture background dream/extract promises and expose
  via consumePendingMemoryTaskPromises(); useGeminiStream listens
  post-turn and emits 'Updated N memories' notification for background tasks

Feature 4 - Memory Count Badge:
- Add isMemoryOp field to IndividualToolCallDisplay
- Add memoryWriteCount/memoryReadCount to HistoryItemToolGroup
- Add detectMemoryOp() in useReactToolScheduler using isAutoMemPath
- ToolGroupMessage renders '● Recalled N memories, Wrote N memories' badge
  at the top of tool groups that touch memory files

Fix: process.env bracket-access in paths.ts (noPropertyAccessFromIndexSignature)
Fix: MemoryDialog.test.tsx mock useSettings to satisfy SettingsProvider requirement

* fix(memory-ui): auto-approve memory writes, collapse memory tool groups, fix MEMORY.md path

Problem 1 - Auto-approve memory file operations:
- write-file.ts: getDefaultPermission() checks isAutoMemPath; returns 'allow'
  for managed auto-memory files, 'ask' for all other files
- edit.ts: same pattern

Problem 2 - Feature 4 UX: collapse memory-only tool groups:
- ToolGroupMessage: detect when all tool calls have isMemoryOp set (pure memory
  group) and all are complete; render compact '● Recalled/Wrote N memories
  (ctrl+o to expand)' instead of individual tool call rows
- ctrl+o toggles expand/collapse when isFocused and group is memory-only
- Mixed groups (memory + other tools) keep badge-at-top behaviour
- Expanded state shows individual tool calls with '● Memory operations
  (ctrl+o to collapse)' header

Problem 3 - MEMORY.md path mismatch:
- prompt.ts: Step 2 now references full absolute path ${memoryDir}/MEMORY.md
  so the model writes to the correct location inside the memory directory,
  not to the parent project directory

Fix tests:
- write-file.test.ts: add getProjectRoot to mockConfigInternal
- prompt.test.ts: update assertion to match full-path section header

* fix(memory-ui): fix duplicate notification, broken ctrl+o, and Edit tool detection

- Remove duplicate 'Saved N memories' notification: the tool group badge already
  shows 'Wrote N memories'; the separate HistoryItemMemorySaved addItem after
  onComplete was double-counting. Keep only the background-task path
  (consumePendingMemoryTaskPromises).

- Remove ctrl+o expand: Ink's Static area freezes items on first render and
  cannot respond to user input. useInput/useState(isExpanded) in a Static item
  is a no-op. Removed the dead code; memory-only groups now always render as
  the compact summary (no fake interactive hint).

- Fix Edit tool detection: detectMemoryOp was checking for 'edit_file' but the
  real tool name constant is 'edit'. Also removed non-existent 'create_file'
  (write_file covers all writes). Now editing MEMORY.md is correctly identified
  as a memory write op, collapses to 'Wrote N memories', and is auto-approved.

* fix(dream): run /dream as a visible submit_prompt turn, not a silent background agent

The previous implementation ran an AgentHeadless background agent that could
take 5+ minutes with zero UI feedback — user saw a blank screen for the entire
duration and then at most one line of text.

Fix: /dream now returns submit_prompt with the consolidation task prompt so it
runs as a regular AI conversation turn. Tool calls (read_file, write_file, edit,
grep_search, list_directory, glob) are immediately visible as collapsed tool
groups as the model works through the memory files — identical UX to Claude Code.

Also export buildConsolidationTaskPrompt from dreamAgentPlanner so dreamCommand
can reuse the same detailed consolidation prompt that was already written.

* fix(memory): auto-allow ls/glob/grep on memory base directory

Add getMemoryBaseDir() to getDefaultPermission() allow list in ls.ts,
glob.ts, and grep.ts — mirrors the existing pattern in read-file.ts.

Without this, ListFiles/Glob/Grep on ~/.qwen/* would trigger an
approval dialog, blocking /dream at its very first step.

* fix(background): prevent permission prompt hangs in background agents

Match Claude Code's headless-agent intent: background memory agents must never
block on interactive permission prompts.

Wrap background runtime config so getApprovalMode() returns YOLO, ensuring any
ask decision is auto-approved instead of hanging forever. Add regression test
covering the wrapped approval mode.

* fix(memory): run auto extract through forked agent

Make managed auto-memory extraction follow the Claude Code architecture:
background extraction now uses a forked agent to read/write memory files
directly, instead of planning patches and applying them with a separate
filesystem pipeline.

Keep the old patch/model path only as fallback if the forked agent fails.
Add regression tests covering the new execution path and tool whitelist.

* refactor(memory): remove legacy extract fallback pipeline

Delete the old patch/model/heuristic extraction path entirely.
Managed auto-memory extract now runs only through the forked-agent
execution flow, with no planner/apply fallback stages remaining.

Also remove obsolete exports/tests and update scheduler/integration
coverage to use the forked-agent-only architecture.

* refactor(memory): move auxiliary files out of memory/ directory

meta.json, extract-cursor.json, and consolidation.lock are internal
bookkeeping files, not user-visible memories. Move them one level up
to the project state dir (parent of memory/) so that the memory/
directory contains only MEMORY.md and topic files, matching the
clean layout of the upstream reference implementation.

Add getAutoMemoryProjectStateDir() helper in paths.ts and update the
three path accessors + store.test.ts path assertions accordingly.

* fix(memory): record lastDreamAt after manual /dream run

The /dream command submits a prompt to the main agent (submit_prompt),
which writes memory files directly. Because it bypasses dreamScheduler,
meta.json was never updated and /memory always showed 'never'.

Fix by:
- Exporting writeDreamManualRunToMetadata() from dream.ts
- Adding optional onComplete callback to SubmitPromptActionReturn and
  SubmitPromptResult (types.ts / commands/types.ts)
- Propagating onComplete through slashCommandProcessor.ts
- Firing onComplete after turn completion in useGeminiStream.ts
- Providing the callback in dreamCommand.ts to write lastDreamAt

* fix(memory): remove scope params from /remember in managed auto-memory mode

--global/--project are legacy save_memory tool concepts. In managed
auto-memory mode the forked agent decides the appropriate type
(user/feedback/project/reference) based on the content of the fact.

Also improve the prompt wording to explicitly ask the agent to choose
the correct type, reducing the tendency to default to 'project'.

* feat(ui): show '✦ dreaming' indicator in footer during background dream

Subscribe to getManagedAutoMemoryDreamTaskRegistry() in Footer via a
useDreamRunning() hook. While any dream task for the current project is
pending or running, display '✦ dreaming' in the right section of the
footer bar, between Debug Mode and context usage.

* refactor(memory): align dream/extract infrastructure with Claude Code patterns

Five improvements based on Claude Code parity audit:

1. Memoize getAutoMemoryRoot (paths.ts)
   - Add _autoMemoryRootCache Map, keyed by projectRoot
   - findCanonicalGitRoot() walks the filesystem per call; memoize avoids
     repeated git-tree traversal on hot-path schedulers/scanners
   - Expose clearAutoMemoryRootCache() for test teardown

2. Lock file stores PID + isProcessRunning reclaim (dreamScheduler.ts)
   - acquireDreamLock() writes process.pid to the lock file body
   - lockExists() reads PID and calls process.kill(pid, 0); dead/missing
     PID reclaims the lock immediately instead of waiting 2h
   - Stale threshold reduced to 1h (PID-reuse guard, same as CC)

3. Session scan throttle (dreamScheduler.ts)
   - Add SESSION_SCAN_INTERVAL_MS = 10min (same as CC)
   - Add lastSessionScanAt Map<projectRoot, number> to ManagedAutoMemoryDreamRuntime
   - When time-gate passes but session-gate doesn't, throttle prevents
     re-scanning the filesystem on every user turn

4. mtime-based session counting (dreamScheduler.ts)
   - Replace fragile recentSessionIdsSinceDream Set in meta.json with
     filesystem mtime scan (listSessionsTouchedSince)
   - Mirrors Claude Code's listSessionsTouchedSince: reads session JSONL
     files from Storage.getProjectDir()/chats/, filters by mtime > lastDreamAt
   - Immune to meta.json corruption/loss; no per-turn metadata write
   - ManagedAutoMemoryDreamRuntime accepts injectable SessionScannerFn
     for clean unit testing without real session files

5. Extraction mutual exclusion extended to write_file/edit (extractScheduler.ts)
   - historySliceUsesMemoryTool() now checks write_file/edit/replace/create_file
     tool calls whose file_path is within isAutoMemPath()
   - Previously only detected save_memory; missed direct file writes by
     the main agent, causing redundant background extraction

* docs(memory): add user-facing memory docs, i18n for all locales, simplify /forget

- Add docs/users/features/memory.md: comprehensive user-facing guide covering
  QWEN.md instructions, auto-memory behaviour, all memory commands, and
  troubleshooting; replaces the placeholder auto-memory.md
- Update docs/users/features/_meta.ts: rename entry auto-memory → memory
- Update docs/users/features/commands.md: add /init, /remember, /forget,
  /dream rows; fix /memory description; remove /init duplicate
- Update docs/users/configuration/settings.md: add memory.* settings section
  (enableManagedAutoMemory, enableManagedAutoDream) between tools and permissions
- Remove /forget --apply flag: preview-then-apply flow replaced with direct
  deletion; update forgetCommand.ts, en.js, zh.js accordingly
- Add all auto-memory i18n keys to de, ja, pt, ru locales (18 keys each):
  Open auto-memory folder, Auto-memory/Auto-dream status lines, never/on/off,
  ✦ dreaming, /forget and /remember usage strings, all managed-memory messages
- Remove dead save_memory branch from extractScheduler.partWritesToMemory()
- Add ✦ dreaming indicator to Footer.tsx with i18n; fix Footer.test.tsx mocks
- Refactor MemoryDialog.tsx auto-dream status line to use i18n
- Remove save_memory tool (memoryTool.ts/test); clean up webui references
- Add extractionPlanner.ts, const.ts and associated tests
- Delete stale docs/users/configuration/memory.md and
  docs/developers/tools/memory.md (content superseded)

* refactor(memory): remove all Claude Code references from comments and test names

* test(memory): remove empty placeholder test files that cause vitest to fail

* fix eslint

* fix test in windows

* fix test

* fix(memory): address critical review findings from PR #3087

- fix(read-file): narrow auto-allow from getMemoryBaseDir() (~/.qwen) to
  isAutoMemPath(projectRoot) to prevent exposing settings.json / OAuth
  credentials without user approval (wenshao review)

- fix(forget): per-entry deletion instead of whole-file unlink
  - assign stable per-entry IDs (relativePath:index for multi-entry files)
    so the model can target individual entries without removing siblings
  - rewrite file keeping unmatched entries; only unlink when file becomes
    empty (wenshao review)

- fix(entries): round-trip correctness for multi-entry new-format bodies
  - parseAutoMemoryEntries: plain-text line closes current entry and opens
    a new one (was silently ignored when current was already set)
  - renderAutoMemoryBody: emit blank line between adjacent entries so the
    parser can detect entry boundaries on re-read (wenshao review)

- fix(entries): resolve two CodeQL polynomial-regex alerts
  - indentedMatch: \s{2,}(?:[-*]\s+)? → [\t ]{2,}(?:[-*][\t ]+)?
  - topLevelMatch: :\s*(.+)$ → :[ \t]*(\S.*)$
  (github-advanced-security review)

- fix(scan.test): use forward-slash literal for relativePath expectation
  since listMarkdownFiles() normalises all separators to '/' on all
  platforms including Windows

* fix(memory): replace isAutoMemPath startsWith with path.relative()

Using path.relative() instead of string startsWith() is more robust
across platforms — it correctly handles Windows path-separator
differences and avoids potential edge cases where a path prefix match
could succeed on non-separator boundaries.

Addresses github-actions review item 3 (PR #3087).

* feat(telemetry): add auto-memory telemetry instrumentation

Add OpenTelemetry logs + metrics for the five auto-memory lifecycle
events: extract, dream, recall, forget, and remember.

Telemetry layer (packages/core/src/telemetry/):
- constants.ts: 5 new event-name constants
  (qwen-code.memory.{extract,dream,recall,forget,remember})
- types.ts: 5 new event classes with typed constructor params
  (MemoryExtractEvent, MemoryDreamEvent, MemoryRecallEvent,
   MemoryForgetEvent, MemoryRememberEvent)
- metrics.ts: 8 new OTel instruments (5 Counters + 3 Histograms)
  with recordMemoryXxx() helpers; registered inside initializeMetrics()
- loggers.ts: logMemoryExtract/Dream/Recall/Forget/Remember() — each
  emits a structured log record and calls its recordXxx() counterpart
- index.ts: re-exports all new symbols

Instrumentation call-sites:
- extractScheduler.ts ManagedAutoMemoryExtractRuntime.runTask():
  emits extract event with trigger=auto, completed/failed status,
  patches_count, touched_topics, and wall-clock duration
- dream.ts runManagedAutoMemoryDream():
  emits dream event with trigger=auto, updated/noop status,
  deduped_entries, touched_topics, and duration; covers both
  agent-planner and mechanical fallback paths
- recall.ts resolveRelevantAutoMemoryPromptForQuery():
  emits recall event with strategy, docs_scanned/selected, and
  duration; covers model, heuristic, and none paths
- forget.ts forgetManagedAutoMemoryEntries():
  emits forget event with removed_entries_count, touched_topics,
  and selection_strategy (model/heuristic/none)
- rememberCommand.ts action():
  emits remember event with topic=managed|legacy at command
  invocation time (before agent decides the actual memory type)

* refactor(telemetry): remove memory forget/remember telemetry events

Remove EVENT_MEMORY_FORGET and EVENT_MEMORY_REMEMBER along with all
associated infrastructure that is no longer needed:

- constants.ts: remove EVENT_MEMORY_FORGET, EVENT_MEMORY_REMEMBER
- types.ts: remove MemoryForgetEvent, MemoryRememberEvent classes
- metrics.ts: remove MEMORY_FORGET_COUNT, MEMORY_REMEMBER_COUNT constants,
  memoryForgetCounter, memoryRememberCounter module vars,
  their initialization in initializeMetrics(), and
  recordMemoryForgetMetrics(), recordMemoryRememberMetrics() functions
- loggers.ts: remove logMemoryForget(), logMemoryRemember() functions
  and their imports
- index.ts: remove all re-exports for the above symbols
- memory/forget.ts: remove logMemoryForget call-site and import
- cli/rememberCommand.ts: remove logMemoryRemember call-sites and import

* change default value

* fix forked agent

* refactor(background): unify fork primitives into runForkedAgent + cleanup

- Merge runForkedQuery into runForkedAgent via TypeScript overloads:
  with cacheSafeParams → GeminiChat single-turn path (ForkedQueryResult)
  without cacheSafeParams → AgentHeadless multi-turn path (ForkedAgentResult)
- Delete forkedQuery.ts; move its test to background/forkedAgent.cache.test.ts
- Remove forkedQuery export from followup/index.ts
- Migrate all callers (suggestionGenerator, speculation, btwCommand, client)
  to import from background/forkedAgent
- Add getFastModel() / setFastModel() to Config; expose in CLI config init
  and ModelDialog / modelCommand
- Remove resolveFastModel() from AppContainer — now delegated to config.getFastModel()
- Strip Claude Code references from code comments

* fix(memory): address wenshao's critical review findings

- dream.ts: writeDreamManualRunToMetadata now persists lastDreamSessionId
  and resets recentSessionIdsSinceDream, preventing auto-dream from firing
  again in the same session after a manual /dream
- config.ts: gate managed auto-memory injection on getManagedAutoMemoryEnabled();
  when disabled, previously saved memories are no longer injected into new sessions
- rememberCommand.ts: remove legacy save_memory branch (tool was removed);
  fall back to submit_prompt directing agent to write to QWEN.md instead
- BuiltinCommandLoader.ts: only register /dream and /forget when managed
  auto-memory is enabled, matching the feature's runtime availability
- forget.ts: return early in forgetManagedAutoMemoryMatches when matches is
  empty, avoiding unnecessary directory scaffolding as a side effect

* fix test

* fix ci test

* feat(memory): align extract/dream agents to Claude Code patterns

- fix(client): move saveCacheSafeParams before early-return paths so
  extract agents always have cache params available (fixes extract never
  triggering in skipNextSpeakerCheck mode)

- feat(extract): add read-only shell tool + memory-scoped write
  permissions; create inline createMemoryScopedAgentConfig() with
  PermissionManager wrapper (isToolEnabled + evaluate) that allows only
  read-only shell commands and write/edit within the auto-memory dir

- feat(extract): align prompt to Claude Code patterns — manifest block
  listing existing files, parallel read-then-write strategy, two-step
  save (memory file then index)

- feat(dream): remove mechanical fallback; runManagedAutoMemoryDream is
  now agent-only and throws without config

- feat(dream): align prompt to Claude Code 4-phase structure
  (Orient/Gather/Consolidate/Prune+Index); add narrow transcript grep,
  relative→absolute date conversion, stale index pruning, index size cap

- fix(permissions): add isToolEnabled() to MemoryScopedPermissionManager
  to prevent TypeError crash in CoreToolScheduler._schedule

- test: update dreamScheduler tests to mock dream.js; replace removed
  mechanical-dedup test with scheduler infrastructure verification

* move doc to design

* refactor(memory): unify extract+dream background task management into MemoryBackgroundTaskHub

- Add memoryTaskHub.ts: single BackgroundTaskRegistry + BackgroundTaskDrainer shared
  by all memory background tasks; exposes listExtractTasks() / listDreamTasks()
  typed query helpers and a unified drain() method
- extractScheduler: ManagedAutoMemoryExtractRuntime accepts hub via constructor
  (defaults to defaultMemoryTaskHub); test factory gets isolated fresh hub
- dreamScheduler: same pattern — sessionScanner + hub injection; BackgroundTask-
  Scheduler initialized from injected hub; test factory gets isolated hub
- status.ts: replace two separate getRegistry() calls with defaultMemoryTaskHub
  typed query methods
- Footer.tsx (useDreamRunning): subscribe to shared registry, filter by
  DREAM_TASK_TYPE so extract tasks do not trigger the dream spinner
- index.ts: re-export memoryTaskHub.ts so defaultMemoryTaskHub/DREAM_TASK_TYPE/
  EXTRACT_TASK_TYPE are available as top-level package exports

* refactor(background): introduce general-purpose BackgroundTaskHub

Replace memory-specific MemoryBackgroundTaskHub with a domain-agnostic
BackgroundTaskHub in the background/ layer. Any future background task
runtime (3rd, 4th, …) plugs in by accepting a hub via constructor
injection — no new infrastructure required.

Changes:
- Add background/taskHub.ts: BackgroundTaskHub (registry + drainer +
  createScheduler() + listByType(taskType, projectRoot?)) and the
  globalBackgroundTaskHub singleton. Zero knowledge of any task type.
- Delete memory/memoryTaskHub.ts: its narrow listExtractTasks /
  listDreamTasks helpers are replaced by the generic listByType() call.
- Move EXTRACT_TASK_TYPE to extractScheduler.ts (owned by the runtime
  that defines it); replace 3 hardcoded string literals with the const.
- Move DREAM_TASK_TYPE to dreamScheduler.ts; use hub.createScheduler()
  instead of manually wiring new BackgroundTaskScheduler(reg, drain).
- status.ts: globalBackgroundTaskHub.listByType(EXTRACT_TASK_TYPE, ...)
- Footer.tsx: globalBackgroundTaskHub.registry (shared, filtered by type)
- index.ts: export background/taskHub.js; drop memory/memoryTaskHub.js

* test(background): add BackgroundTaskHub unit tests and hub isolation checks

- background/taskHub.test.ts (11 tests):
  - createScheduler(): tasks registered via scheduler appear in hub registry;
    multiple calls return distinct scheduler instances
  - listByType(): filters by taskType, filters by projectRoot, returns []
    for unknown types, two types co-exist in registry but stay separated
  - drain(): resolves false on timeout, resolves true when tasks complete,
    resolves true immediately when no tasks in flight
  - isolation: tasks in hubA do not appear in hubB
  - globalBackgroundTaskHub: is a BackgroundTaskHub instance with registry/drainer

- extractScheduler.test.ts (+1 test):
  - factory-created runtimes have isolated registries; tasks in runtimeA
    are invisible to runtimeB; all tasks carry EXTRACT_TASK_TYPE

- dreamScheduler.test.ts (+1 test):
  - factory-created runtimes have isolated registries; tasks in runtimeA
    are invisible to runtimeB; all tasks carry DREAM_TASK_TYPE

* refactor(memory): consolidate all memory state into MemoryManager

Replace BackgroundTaskRegistry/Drainer/Scheduler/Hub helper classes and
module-level globals with a single MemoryManager class owned by Config.

## Changes

### New
- packages/core/src/memory/manager.ts — MemoryManager with:
  - scheduleExtract / scheduleDream (inline queuing + deduplication logic)
  - recall / forget / selectForgetCandidates / forgetMatches
  - getStatus / drain / appendToUserMemory
  - subscribe(listener) compatible with useSyncExternalStore
  - storeWith() atomic record registration (no double-notify)
  - Distinct skippedReason 'scan_throttled' vs 'min_sessions' for dream
- packages/core/src/utils/forkedAgent.ts — pure cache util (moved from background/)
- packages/core/src/utils/sideQuery.ts — pure util (moved from auxiliary/)

### Deleted
- background/taskRegistry, taskDrainer, taskScheduler, taskHub and all tests
- background/forkedAgent (moved to utils/)
- auxiliary/sideQuery (moved to utils/)
- memory/extractScheduler, dreamScheduler, state and all tests

### Modified
- config/config.ts — Config owns MemoryManager instance; getMemoryManager()
- core/client.ts — all memory ops via config.getMemoryManager()
- core/client.test.ts — mock MemoryManager instead of individual modules
- memory/status.ts — accepts MemoryManager param, drops globalBackgroundTaskHub
- index.ts — memory exports reduced from 14 modules to 5 (manager/types/paths/store/const)
- cli/commands/dreamCommand.ts — via config.getMemoryManager()
- cli/commands/forgetCommand.ts — via config.getMemoryManager()
- cli/components/Footer.tsx — useSyncExternalStore replacing setInterval polling
- cli/components/Footer.test.tsx — add getMemoryManager mock
2026-04-16 20:05:45 +08:00

1127 lines
36 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { act, renderHook, waitFor } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
import type {
CommandContext,
ConfirmShellCommandsActionReturn,
SlashCommand,
} from '../commands/types.js';
import { CommandKind } from '../commands/types.js';
import type { LoadedSettings } from '../../config/settings.js';
import { MessageType } from '../types.js';
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import {
type GeminiClient,
SlashCommandStatus,
ToolConfirmationOutcome,
makeFakeConfig,
} from '@qwen-code/qwen-code-core';
const { logSlashCommand } = vi.hoisted(() => ({
logSlashCommand: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...original,
logSlashCommand,
getIdeInstaller: vi.fn().mockReturnValue(null),
};
});
const { mockProcessExit } = vi.hoisted(() => ({
mockProcessExit: vi.fn((_code?: number): never => undefined as never),
}));
vi.mock('node:process', () => {
const mockProcess: Partial<NodeJS.Process> = {
exit: mockProcessExit,
platform: 'sunos',
cwd: () => '/fake/dir',
} as unknown as NodeJS.Process;
return {
...mockProcess,
default: mockProcess,
};
});
const mockBuiltinLoadCommands = vi.fn();
vi.mock('../../services/BuiltinCommandLoader.js', () => ({
BuiltinCommandLoader: vi.fn().mockImplementation(() => ({
loadCommands: mockBuiltinLoadCommands,
})),
}));
const mockFileLoadCommands = vi.fn();
vi.mock('../../services/FileCommandLoader.js', () => ({
FileCommandLoader: vi.fn().mockImplementation(() => ({
loadCommands: mockFileLoadCommands,
})),
}));
const mockMcpLoadCommands = vi.fn();
vi.mock('../../services/McpPromptLoader.js', () => ({
McpPromptLoader: vi.fn().mockImplementation(() => ({
loadCommands: mockMcpLoadCommands,
})),
}));
vi.mock('../contexts/SessionContext.js', () => ({
useSessionStats: vi.fn(() => ({ stats: {} })),
}));
const { mockRunExitCleanup } = vi.hoisted(() => ({
mockRunExitCleanup: vi.fn(),
}));
vi.mock('../../utils/cleanup.js', () => ({
runExitCleanup: mockRunExitCleanup,
}));
vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
function createTestCommand(
overrides: Partial<SlashCommand>,
kind: CommandKind = CommandKind.BUILT_IN,
): SlashCommand {
return {
name: 'test',
description: 'a test command',
kind,
...overrides,
};
}
describe('useSlashCommandProcessor', () => {
const mockAddItem = vi.fn();
const mockClearItems = vi.fn();
const mockLoadHistory = vi.fn();
const mockOpenThemeDialog = vi.fn();
const mockOpenAuthDialog = vi.fn();
const mockOpenMemoryDialog = vi.fn();
const mockOpenModelDialog = vi.fn();
const mockSetQuittingMessages = vi.fn();
const mockConfig = makeFakeConfig({});
mockConfig.getChatRecordingService = vi.fn().mockReturnValue({
recordSlashCommand: vi.fn(),
});
const mockSettings = {} as LoadedSettings;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(BuiltinCommandLoader).mockClear();
mockBuiltinLoadCommands.mockResolvedValue([]);
mockFileLoadCommands.mockResolvedValue([]);
mockMcpLoadCommands.mockResolvedValue([]);
mockOpenModelDialog.mockClear();
mockOpenMemoryDialog.mockClear();
});
const setupProcessorHook = (
builtinCommands: SlashCommand[] = [],
fileCommands: SlashCommand[] = [],
mcpCommands: SlashCommand[] = [],
setIsProcessing = vi.fn(),
) => {
mockBuiltinLoadCommands.mockResolvedValue(Object.freeze(builtinCommands));
mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands));
mockMcpLoadCommands.mockResolvedValue(Object.freeze(mcpCommands));
const { result } = renderHook(() =>
useSlashCommandProcessor(
mockConfig,
mockSettings,
mockAddItem,
mockClearItems,
mockLoadHistory,
vi.fn(), // refreshStatic
vi.fn(), // toggleVimEnabled
false, // isProcessing
setIsProcessing,
vi.fn(), // setGeminiMdFileCount
{
openAuthDialog: mockOpenAuthDialog,
openThemeDialog: mockOpenThemeDialog,
openEditorDialog: vi.fn(),
openMemoryDialog: mockOpenMemoryDialog,
openSettingsDialog: vi.fn(),
openModelDialog: mockOpenModelDialog,
openTrustDialog: vi.fn(),
openPermissionsDialog: vi.fn(),
openApprovalModeDialog: vi.fn(),
openResumeDialog: vi.fn(),
quit: mockSetQuittingMessages,
setDebugMessage: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
openSubagentCreateDialog: vi.fn(),
openAgentsManagerDialog: vi.fn(),
},
new Map(), // extensionsUpdateState
true, // isConfigInitialized
null, // logger
),
);
return result;
};
describe('Initialization and Command Loading', () => {
it('should initialize CommandService with all required loaders', () => {
setupProcessorHook();
expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig);
expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig);
expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig);
});
it('should call loadCommands and populate state after mounting', async () => {
const testCommand = createTestCommand({ name: 'test' });
const result = setupProcessorHook([testCommand]);
await waitFor(() => {
expect(result.current.slashCommands).toHaveLength(1);
});
expect(result.current.slashCommands[0]?.name).toBe('test');
expect(mockBuiltinLoadCommands).toHaveBeenCalledTimes(1);
expect(mockFileLoadCommands).toHaveBeenCalledTimes(1);
expect(mockMcpLoadCommands).toHaveBeenCalledTimes(1);
});
it('should provide an immutable array of commands to consumers', async () => {
const testCommand = createTestCommand({ name: 'test' });
const result = setupProcessorHook([testCommand]);
await waitFor(() => {
expect(result.current.slashCommands).toHaveLength(1);
});
const commands = result.current.slashCommands;
expect(() => {
// @ts-expect-error - We are intentionally testing a violation of the readonly type.
commands.push(createTestCommand({ name: 'rogue' }));
}).toThrow(TypeError);
});
it('should override built-in commands with file-based commands of the same name', async () => {
const builtinAction = vi.fn();
const fileAction = vi.fn();
const builtinCommand = createTestCommand({
name: 'override',
description: 'builtin',
action: builtinAction,
});
const fileCommand = createTestCommand(
{ name: 'override', description: 'file', action: fileAction },
CommandKind.FILE,
);
const result = setupProcessorHook([builtinCommand], [fileCommand]);
await waitFor(() => {
// The service should only return one command with the name 'override'
expect(result.current.slashCommands).toHaveLength(1);
});
await act(async () => {
await result.current.handleSlashCommand('/override');
});
// Only the file-based command's action should be called.
expect(fileAction).toHaveBeenCalledTimes(1);
expect(builtinAction).not.toHaveBeenCalled();
});
});
describe('Command Execution Logic', () => {
it('should display an error for an unknown command', async () => {
const result = setupProcessorHook();
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
await act(async () => {
await result.current.handleSlashCommand('/nonexistent');
});
// Expect 2 calls: one for the user's input, one for the error message.
expect(mockAddItem).toHaveBeenCalledTimes(2);
expect(mockAddItem).toHaveBeenLastCalledWith(
{
type: MessageType.ERROR,
text: 'Unknown command: /nonexistent',
},
expect.any(Number),
);
});
it('should display help for a parent command invoked without a subcommand', async () => {
const parentCommand: SlashCommand = {
name: 'parent',
description: 'a parent command',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'child1',
description: 'First child.',
kind: CommandKind.BUILT_IN,
},
],
};
const result = setupProcessorHook([parentCommand]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/parent');
});
expect(mockAddItem).toHaveBeenCalledTimes(2);
expect(mockAddItem).toHaveBeenLastCalledWith(
{
type: MessageType.INFO,
text: expect.stringContaining(
"Command '/parent' requires a subcommand.",
),
},
expect.any(Number),
);
});
it('should correctly find and execute a nested subcommand', async () => {
const childAction = vi.fn();
const parentCommand: SlashCommand = {
name: 'parent',
description: 'a parent command',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'child',
description: 'a child command',
kind: CommandKind.BUILT_IN,
action: childAction,
},
],
};
const result = setupProcessorHook([parentCommand]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/parent child with args');
});
expect(childAction).toHaveBeenCalledTimes(1);
expect(childAction).toHaveBeenCalledWith(
expect.objectContaining({
invocation: expect.objectContaining({
name: 'child',
args: 'with args',
}),
services: expect.objectContaining({
config: mockConfig,
}),
ui: expect.objectContaining({
addItem: expect.any(Function),
}),
}),
'with args',
);
});
it('sets isProcessing to false if the the input is not a command', async () => {
const setMockIsProcessing = vi.fn();
const result = setupProcessorHook([], [], [], setMockIsProcessing);
await act(async () => {
await result.current.handleSlashCommand('imnotacommand');
});
expect(setMockIsProcessing).not.toHaveBeenCalled();
});
it('sets isProcessing to false if the command has an error', async () => {
const setMockIsProcessing = vi.fn();
const failCommand = createTestCommand({
name: 'fail',
action: vi.fn().mockRejectedValue(new Error('oh no!')),
});
const result = setupProcessorHook(
[failCommand],
[],
[],
setMockIsProcessing,
);
await act(async () => {
await result.current.handleSlashCommand('/fail');
});
expect(setMockIsProcessing).toHaveBeenNthCalledWith(1, true);
expect(setMockIsProcessing).toHaveBeenNthCalledWith(2, false);
});
it('should set isProcessing to true during execution and false afterwards', async () => {
const mockSetIsProcessing = vi.fn();
const command = createTestCommand({
name: 'long-running',
action: () => new Promise((resolve) => setTimeout(resolve, 50)),
});
const result = setupProcessorHook([command], [], [], mockSetIsProcessing);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
const executionPromise = act(async () => {
await result.current.handleSlashCommand('/long-running');
});
// It should be true immediately after starting
expect(mockSetIsProcessing).toHaveBeenNthCalledWith(1, true);
// It should not have been called with false yet
expect(mockSetIsProcessing).not.toHaveBeenCalledWith(false);
await executionPromise;
// After the promise resolves, it should be called with false
expect(mockSetIsProcessing).toHaveBeenNthCalledWith(2, false);
expect(mockSetIsProcessing).toHaveBeenCalledTimes(2);
});
});
describe('Action Result Handling', () => {
it('should handle "dialog: theme" action', async () => {
const command = createTestCommand({
name: 'themecmd',
action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'theme' }),
});
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/themecmd');
});
expect(mockOpenThemeDialog).toHaveBeenCalled();
});
it('should handle "dialog: model" action', async () => {
const command = createTestCommand({
name: 'modelcmd',
action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'model' }),
});
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/modelcmd');
});
expect(mockOpenModelDialog).toHaveBeenCalled();
});
it('should handle "dialog: memory" action', async () => {
const command = createTestCommand({
name: 'memorycmd',
action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'memory' }),
});
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/memorycmd');
});
expect(mockOpenMemoryDialog).toHaveBeenCalled();
});
it('should pass interactive execution mode to command actions', async () => {
const action = vi.fn().mockResolvedValue({
type: 'message',
messageType: 'info',
content: 'ok',
});
const command = createTestCommand({
name: 'interactivecmd',
action,
});
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/interactivecmd');
});
expect(action).toHaveBeenCalledWith(
expect.objectContaining({ executionMode: 'interactive' }),
'',
);
});
it('should handle "load_history" action', async () => {
const mockClient = {
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
} as unknown as GeminiClient;
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);
const command = createTestCommand({
name: 'load',
action: vi.fn().mockResolvedValue({
type: 'load_history',
history: [{ type: MessageType.USER, text: 'old prompt' }],
clientHistory: [{ role: 'user', parts: [{ text: 'old prompt' }] }],
}),
});
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/load');
});
expect(mockClearItems).toHaveBeenCalledTimes(1);
expect(mockAddItem).toHaveBeenCalledWith(
{ type: 'user', text: 'old prompt' },
expect.any(Number),
);
});
it('should strip thoughts when handling "load_history" action', async () => {
const mockClient = {
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
} as unknown as GeminiClient;
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);
const historyWithThoughts = [
{
role: 'model',
parts: [{ text: 'response', thoughtSignature: 'CikB...' }],
},
];
const command = createTestCommand({
name: 'loadwiththoughts',
action: vi.fn().mockResolvedValue({
type: 'load_history',
history: [{ type: MessageType.GEMINI, text: 'response' }],
clientHistory: historyWithThoughts,
}),
});
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/loadwiththoughts');
});
expect(mockClient.setHistory).toHaveBeenCalledTimes(1);
expect(mockClient.stripThoughtsFromHistory).toHaveBeenCalledWith();
});
it('should handle a "quit" action', async () => {
const quitAction = vi
.fn()
.mockResolvedValue({ type: 'quit', messages: ['bye'] });
const command = createTestCommand({
name: 'exit',
action: quitAction,
});
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/exit');
});
expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
});
it('should handle "submit_prompt" action returned from a file-based command', async () => {
const fileCommand = createTestCommand(
{
name: 'filecmd',
description: 'A command from a file',
action: async () => ({
type: 'submit_prompt',
content: [{ text: 'The actual prompt from the TOML file.' }],
}),
},
CommandKind.FILE,
);
const result = setupProcessorHook([], [fileCommand]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
let actionResult;
await act(async () => {
actionResult = await result.current.handleSlashCommand('/filecmd');
});
expect(actionResult).toEqual({
type: 'submit_prompt',
content: [{ text: 'The actual prompt from the TOML file.' }],
});
expect(mockAddItem).toHaveBeenCalledWith(
{ type: MessageType.USER, text: '/filecmd' },
expect.any(Number),
);
});
it('should handle "submit_prompt" action returned from a mcp-based command', async () => {
const mcpCommand = createTestCommand(
{
name: 'mcpcmd',
description: 'A command from mcp',
action: async () => ({
type: 'submit_prompt',
content: [{ text: 'The actual prompt from the mcp command.' }],
}),
},
CommandKind.MCP_PROMPT,
);
const result = setupProcessorHook([], [], [mcpCommand]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
let actionResult;
await act(async () => {
actionResult = await result.current.handleSlashCommand('/mcpcmd');
});
expect(actionResult).toEqual({
type: 'submit_prompt',
content: [{ text: 'The actual prompt from the mcp command.' }],
});
expect(mockAddItem).toHaveBeenCalledWith(
{ type: MessageType.USER, text: '/mcpcmd' },
expect.any(Number),
);
});
});
describe('Shell Command Confirmation Flow', () => {
// Use a generic vi.fn() for the action. We will change its behavior in each test.
const mockCommandAction = vi.fn();
const shellCommand = createTestCommand({
name: 'shellcmd',
action: mockCommandAction,
});
beforeEach(() => {
// Reset the mock before each test
mockCommandAction.mockClear();
// Default behavior: request confirmation
mockCommandAction.mockResolvedValue({
type: 'confirm_shell_commands',
commandsToConfirm: ['rm -rf /'],
originalInvocation: { raw: '/shellcmd' },
} as ConfirmShellCommandsActionReturn);
});
it('should set confirmation request when action returns confirm_shell_commands', async () => {
const result = setupProcessorHook([shellCommand]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
// This is intentionally not awaited, because the promise it returns
// will not resolve until the user responds to the confirmation.
act(() => {
result.current.handleSlashCommand('/shellcmd');
});
// We now wait for the state to be updated with the request.
await waitFor(() => {
expect(result.current.shellConfirmationRequest).not.toBeNull();
});
expect(result.current.shellConfirmationRequest?.commands).toEqual([
'rm -rf /',
]);
});
it('should do nothing if user cancels confirmation', async () => {
const result = setupProcessorHook([shellCommand]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
act(() => {
result.current.handleSlashCommand('/shellcmd');
});
// Wait for the confirmation dialog to be set
await waitFor(() => {
expect(result.current.shellConfirmationRequest).not.toBeNull();
});
const onConfirm = result.current.shellConfirmationRequest?.onConfirm;
expect(onConfirm).toBeDefined();
// Change the mock action's behavior for a potential second run.
// If the test is flawed, this will be called, and we can detect it.
mockCommandAction.mockResolvedValue({
type: 'message',
messageType: 'info',
content: 'This should not be called',
});
await act(async () => {
onConfirm!(ToolConfirmationOutcome.Cancel, []); // Pass empty array for safety
});
expect(result.current.shellConfirmationRequest).toBeNull();
// Verify the action was only called the initial time.
expect(mockCommandAction).toHaveBeenCalledTimes(1);
});
it('should re-run command with one-time allowlist on "Proceed Once"', async () => {
const result = setupProcessorHook([shellCommand]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
act(() => {
result.current.handleSlashCommand('/shellcmd');
});
await waitFor(() => {
expect(result.current.shellConfirmationRequest).not.toBeNull();
});
const onConfirm = result.current.shellConfirmationRequest?.onConfirm;
// **Change the mock's behavior for the SECOND run.**
// This is the key to testing the outcome.
mockCommandAction.mockResolvedValue({
type: 'message',
messageType: 'info',
content: 'Success!',
});
await act(async () => {
onConfirm!(ToolConfirmationOutcome.ProceedOnce, ['rm -rf /']);
});
expect(result.current.shellConfirmationRequest).toBeNull();
// The action should have been called twice (initial + re-run).
await waitFor(() => {
expect(mockCommandAction).toHaveBeenCalledTimes(2);
});
// We can inspect the context of the second call to ensure the one-time list was used.
const secondCallContext = mockCommandAction.mock
.calls[1][0] as CommandContext;
expect(
secondCallContext.session.sessionShellAllowlist.has('rm -rf /'),
).toBe(true);
// Verify the final success message was added.
expect(mockAddItem).toHaveBeenCalledWith(
{ type: MessageType.INFO, text: 'Success!' },
expect.any(Number),
);
// Verify the session-wide allowlist was NOT permanently updated.
// Re-render the hook by calling a no-op command to get the latest context.
await act(async () => {
result.current.handleSlashCommand('/no-op');
});
const finalContext = result.current.commandContext;
expect(finalContext.session.sessionShellAllowlist.size).toBe(0);
});
it('should re-run command and update session allowlist on "Proceed Always"', async () => {
const result = setupProcessorHook([shellCommand]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
act(() => {
result.current.handleSlashCommand('/shellcmd');
});
await waitFor(() => {
expect(result.current.shellConfirmationRequest).not.toBeNull();
});
const onConfirm = result.current.shellConfirmationRequest?.onConfirm;
mockCommandAction.mockResolvedValue({
type: 'message',
messageType: 'info',
content: 'Success!',
});
await act(async () => {
onConfirm!(ToolConfirmationOutcome.ProceedAlways, ['rm -rf /']);
});
expect(result.current.shellConfirmationRequest).toBeNull();
await waitFor(() => {
expect(mockCommandAction).toHaveBeenCalledTimes(2);
});
expect(mockAddItem).toHaveBeenCalledWith(
{ type: MessageType.INFO, text: 'Success!' },
expect.any(Number),
);
// Check that the session-wide allowlist WAS updated.
await waitFor(() => {
const finalContext = result.current.commandContext;
expect(finalContext.session.sessionShellAllowlist.has('rm -rf /')).toBe(
true,
);
});
});
});
describe('Command Parsing and Matching', () => {
it('should be case-sensitive', async () => {
const command = createTestCommand({ name: 'test' });
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
// Use uppercase when command is lowercase
await result.current.handleSlashCommand('/Test');
});
// It should fail and call addItem with an error
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Unknown command: /Test',
},
expect.any(Number),
);
});
it('should correctly match an altName', async () => {
const action = vi.fn();
const command = createTestCommand({
name: 'main',
altNames: ['alias'],
description: 'a command with an alias',
action,
});
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/alias');
});
expect(action).toHaveBeenCalledTimes(1);
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({ type: MessageType.ERROR }),
);
});
it('should handle extra whitespace around the command', async () => {
const action = vi.fn();
const command = createTestCommand({ name: 'test', action });
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand(' /test with-args ');
});
expect(action).toHaveBeenCalledWith(expect.anything(), 'with-args');
});
it('should handle `?` as a command prefix', async () => {
const action = vi.fn();
const command = createTestCommand({ name: 'help', action });
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('?help');
});
expect(action).toHaveBeenCalledTimes(1);
});
});
describe('Command Precedence', () => {
it('should override mcp-based commands with file-based commands of the same name', async () => {
const mcpAction = vi.fn();
const fileAction = vi.fn();
const mcpCommand = createTestCommand(
{
name: 'override',
description: 'mcp',
action: mcpAction,
},
CommandKind.MCP_PROMPT,
);
const fileCommand = createTestCommand(
{ name: 'override', description: 'file', action: fileAction },
CommandKind.FILE,
);
const result = setupProcessorHook([], [fileCommand], [mcpCommand]);
await waitFor(() => {
// The service should only return one command with the name 'override'
expect(result.current.slashCommands).toHaveLength(1);
});
await act(async () => {
await result.current.handleSlashCommand('/override');
});
// Only the file-based command's action should be called.
expect(fileAction).toHaveBeenCalledTimes(1);
expect(mcpAction).not.toHaveBeenCalled();
});
it('should prioritize a command with a primary name over a command with a matching alias', async () => {
const quitAction = vi.fn();
const exitAction = vi.fn();
const quitCommand = createTestCommand({
name: 'quit',
altNames: ['exit'],
action: quitAction,
});
const exitCommand = createTestCommand(
{
name: 'exit',
action: exitAction,
},
CommandKind.FILE,
);
// The order of commands in the final loaded array is not guaranteed,
// so the test must work regardless of which comes first.
const result = setupProcessorHook([quitCommand], [exitCommand]);
await waitFor(() => {
expect(result.current.slashCommands).toHaveLength(2);
});
await act(async () => {
await result.current.handleSlashCommand('/exit');
});
// The action for the command whose primary name is 'exit' should be called.
expect(exitAction).toHaveBeenCalledTimes(1);
// The action for the command that has 'exit' as an alias should NOT be called.
expect(quitAction).not.toHaveBeenCalled();
});
it('should add an overridden command to the history', async () => {
const quitCommand = createTestCommand({
name: 'quit',
altNames: ['exit'],
action: vi.fn(),
});
const exitCommand = createTestCommand(
{ name: 'exit', action: vi.fn() },
CommandKind.FILE,
);
const result = setupProcessorHook([quitCommand], [exitCommand]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(2));
await act(async () => {
await result.current.handleSlashCommand('/exit');
});
// It should be added to the history.
expect(mockAddItem).toHaveBeenCalledWith(
{ type: MessageType.USER, text: '/exit' },
expect.any(Number),
);
});
});
describe('Lifecycle', () => {
it('should abort command loading when the hook unmounts', () => {
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
const { unmount } = renderHook(() =>
useSlashCommandProcessor(
mockConfig,
mockSettings,
mockAddItem,
mockClearItems,
mockLoadHistory,
vi.fn(), // refreshStatic
vi.fn(), // toggleVimEnabled
false, // isProcessing
vi.fn(), // setIsProcessing
vi.fn(), // setGeminiMdFileCount
{
openAuthDialog: mockOpenAuthDialog,
openThemeDialog: mockOpenThemeDialog,
openEditorDialog: vi.fn(),
openMemoryDialog: mockOpenMemoryDialog,
openSettingsDialog: vi.fn(),
openModelDialog: vi.fn(),
openTrustDialog: vi.fn(),
openPermissionsDialog: vi.fn(),
openApprovalModeDialog: vi.fn(),
openResumeDialog: vi.fn(),
quit: mockSetQuittingMessages,
setDebugMessage: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
openSubagentCreateDialog: vi.fn(),
openAgentsManagerDialog: vi.fn(),
},
new Map(), // extensionsUpdateState
true, // isConfigInitialized
null, // logger
),
);
unmount();
expect(abortSpy).toHaveBeenCalledTimes(1);
});
});
describe('Slash Command Logging', () => {
const mockCommandAction = vi.fn().mockResolvedValue({ type: 'handled' });
const loggingTestCommands: SlashCommand[] = [
createTestCommand({
name: 'logtest',
action: vi
.fn()
.mockResolvedValue({ type: 'message', content: 'hello world' }),
}),
createTestCommand({
name: 'logwithsub',
subCommands: [
createTestCommand({
name: 'sub',
action: mockCommandAction,
}),
],
}),
createTestCommand({
name: 'fail',
action: vi.fn().mockRejectedValue(new Error('oh no!')),
}),
createTestCommand({
name: 'logalias',
altNames: ['la'],
action: mockCommandAction,
}),
];
beforeEach(() => {
mockCommandAction.mockClear();
vi.mocked(logSlashCommand).mockClear();
});
it('should log a simple slash command', async () => {
const result = setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/logtest');
});
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
command: 'logtest',
subcommand: undefined,
status: SlashCommandStatus.SUCCESS,
}),
);
});
it('logs nothing for a bogus command', async () => {
const result = setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/bogusbogusbogus');
});
expect(logSlashCommand).not.toHaveBeenCalled();
});
it('logs a failure event for a failed command', async () => {
const result = setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/fail');
});
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
command: 'fail',
status: 'error',
subcommand: undefined,
}),
);
});
it('should log a slash command with a subcommand', async () => {
const result = setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/logwithsub sub');
});
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
command: 'logwithsub',
subcommand: 'sub',
}),
);
});
it('should log the command path when an alias is used', async () => {
const result = setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/la');
});
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
command: 'logalias',
}),
);
});
it('should not log for unknown commands', async () => {
const result = setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/unknown');
});
expect(logSlashCommand).not.toHaveBeenCalled();
});
});
});