mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
* 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
1127 lines
36 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|