Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/cli/src/ui/commands/forgetCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export const forgetCommand: SlashCommand = {

const result = await config
.getMemoryManager()
.forgetMatches(config.getProjectRoot(), selection.matches);
.forgetMatches(config.getProjectRoot(), selection.matches, undefined, {
config,
});
return {
type: 'message',
messageType: 'info',
Expand Down
38 changes: 35 additions & 3 deletions packages/cli/src/ui/commands/memoryCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
import { describe, expect, it, vi } from 'vitest';
import { memoryCommand } from './memoryCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';

Expand All @@ -22,7 +23,38 @@ describe('memoryCommand', () => {
});
});

it('does not advertise unsupported subcommands', () => {
expect(memoryCommand.argumentHint).toBeUndefined();
it('advertises the explicit team migration subcommand', () => {
expect(memoryCommand.argumentHint).toBe('[migrate-team]');
});

it('starts team migration only from the explicit subcommand', async () => {
const scheduleMetadataMigration = vi.fn().mockResolvedValue({
status: 'scheduled',
taskId: 'migration-1',
});
const config = {
getTeamMemoryEnabled: vi.fn().mockReturnValue(true),
isTrustedFolder: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/project'),
getMemoryManager: vi.fn().mockReturnValue({
scheduleMetadataMigration,
}),
} as unknown as Config;
const context = createMockCommandContext({
executionMode: 'interactive',
services: { config },
});

const result = await memoryCommand.action?.(context, 'migrate-team');

expect(scheduleMetadataMigration).toHaveBeenCalledWith({
projectRoot: '/project',
scope: 'team',
config,
});
expect(result).toMatchObject({
type: 'message',
messageType: 'info',
});
});
});
59 changes: 55 additions & 4 deletions packages/cli/src/ui/commands/memoryCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,64 @@ import { t } from '../../i18n/index.js';

export const memoryCommand: SlashCommand = {
name: 'memory',
argumentHint: '[migrate-team]',
get description() {
return t('Open the memory manager.');
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
action: async () => ({
type: 'dialog',
dialog: 'memory',
}),
action: async (context, args) => {
const action = args.trim();
if (!action) {
return { type: 'dialog', dialog: 'memory' };
}
if (action !== 'migrate-team') {
return {
type: 'message',
messageType: 'error',
content: t('Usage: /memory migrate-team'),
};
}
const config = context.services.config;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
if (!config.getTeamMemoryEnabled() || !config.isTrustedFolder()) {
return {
type: 'message',
messageType: 'error',
content: t(
'Team memory migration requires enabled team memory in a trusted project.',
),
};
}
const result = await config.getMemoryManager().scheduleMetadataMigration({
projectRoot: config.getProjectRoot(),
scope: 'team',
config,
});
if (result.status === 'skipped') {
return {
type: 'message',
messageType: 'info',
content:
result.skippedReason === 'complete'
? t('Team memory metadata is already complete.')
: t('Team memory migration was not started: {{reason}}', {
reason: result.skippedReason ?? 'unknown',
}),
};
}
return {
type: 'message',
messageType: 'info',
content: t('Team memory migration started (task {{taskId}}).', {
taskId: result.taskId ?? 'unknown',
}),
};
},
};
28 changes: 27 additions & 1 deletion packages/cli/src/ui/hooks/useBackgroundTaskView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,13 @@ const dream = (
| 'skipped';
progressText: string;
error: string;
projectRoot: string;
metadata: Record<string, unknown>;
}> = {},
) => ({
id,
taskType: 'dream' as const,
projectRoot: '/test/project',
projectRoot: overrides.projectRoot ?? '/test/project',
status: overrides.status ?? ('running' as const),
createdAt: new Date(startTimeMs).toISOString(),
updatedAt: new Date(startTimeMs).toISOString(),
Expand Down Expand Up @@ -631,6 +632,31 @@ describe('useBackgroundTaskView', () => {
expect(only.status).toBe('cancelled');
});

it('shows the current project Dream and global User Dream only', () => {
const { config } = makeConfig({
agents: () => [],
shells: () => [],
monitors: () => [],
dreams: () => [
dream('project-dream', 100),
dream('other-project-dream', 200, {
projectRoot: '/other/project',
}),
dream('user-dream', 300, {
projectRoot: '/global/user-memory',
metadata: { scope: 'user' },
}),
],
});

const { result } = renderHook(() => useBackgroundTaskView(config));

expect(result.current.entries.map(entryId)).toEqual([
'user-dream',
'project-dream',
]);
});

it('subscribes to MemoryManager with a dream taskType filter so extract notifies are skipped at the source', () => {
// The taskType filter on MemoryManager.subscribe() is the
// primary perf guard — it prevents the per-UserQuery extract
Expand Down
13 changes: 10 additions & 3 deletions packages/cli/src/ui/hooks/useBackgroundTaskView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,14 @@ export function useBackgroundTaskView(
// call to refresh between the two `const` bindings.
const computeDreamSig = (dreams: readonly MemoryTaskRecord[]): string =>
dreams.map((t) => `${t.id}:${t.status}:${t.updatedAt}`).join('|');
const listVisibleDreams = (): MemoryTaskRecord[] =>
memoryManager
.listTasksByType('dream')
.filter(
(task) =>
task.projectRoot === projectRoot ||
task.metadata?.['scope'] === 'user',
);

// refresh accepts a pre-fetched dream snapshot so the memory
// listener can reuse the same array it computed for its dedup
Expand Down Expand Up @@ -223,8 +231,7 @@ export function useBackgroundTaskView(
// cap the dialog would grow unbounded; with it the user sees all
// running dreams plus the most recent few terminal results
// (mirrors MonitorRegistry.MAX_RETAINED_TERMINAL_MONITORS).
const allDreams =
dreamSnapshot ?? memoryManager.listTasksByType('dream', projectRoot);
const allDreams = dreamSnapshot ?? listVisibleDreams();
const runningDreams = allDreams.filter((t) => t.status === 'running');
const terminalDreams = allDreams
.filter(
Expand Down Expand Up @@ -322,7 +329,7 @@ export function useBackgroundTaskView(
// same status). The fetched snapshot is forwarded to refresh so
// both the gate and the rendered dreamEntries come from one read.
const memoryListener = () => {
const dreams = memoryManager.listTasksByType('dream', projectRoot);
const dreams = listVisibleDreams();
const sig = computeDreamSig(dreams);
if (sig === lastDreamSig) return;
refresh(dreams);
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/agents/forkedAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,12 @@ export interface ForkedAgentResult {
filesTouched: string[];
/** File paths from successful mutating tool results. */
filesWritten?: string[];
/** Aggregate model usage for this isolated agent run. */
usage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
}

/**
Expand Down Expand Up @@ -620,6 +626,7 @@ export async function runForkedAgent(
const filesTouched = new Set<string>();
const pendingMutatingPaths = new Map<string, string[]>();
const filesWritten = new Set<string>();
let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };

const emitter = new AgentEventEmitter();
emitter.on(AgentEventType.TOOL_CALL, (event) => {
Expand All @@ -642,6 +649,13 @@ export async function runForkedAgent(
filesWritten.add(filePath);
}
});
emitter.on(AgentEventType.FINISH, (event) => {
usage = {
inputTokens: event.inputTokens ?? 0,
outputTokens: event.outputTokens ?? 0,
totalTokens: event.totalTokens ?? 0,
};
});

const initialMessages =
params.extraHistory &&
Expand Down Expand Up @@ -710,6 +724,7 @@ export async function runForkedAgent(
finalText,
filesTouched: touched,
filesWritten: written,
usage,
};
}
if (terminateReason !== AgentTerminateMode.GOAL) {
Expand All @@ -719,6 +734,7 @@ export async function runForkedAgent(
finalText,
filesTouched: touched,
filesWritten: written,
usage,
};
}
return {
Expand All @@ -727,6 +743,7 @@ export async function runForkedAgent(
finalText,
filesTouched: touched,
filesWritten: written,
usage,
};
} finally {
// Release the per-fork ToolRegistry so AgentTool / SkillTool
Expand Down
56 changes: 56 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import {
import * as jsonl from '../utils/jsonl-utils.js';
import { checkPriorRead } from '../tools/priorReadEnforcement.js';
import { ToolErrorType } from '../tools/tool-error.js';
import { scanMemoryMetadataCorpusStatus } from '../memory/metadata-migration.js';

function createToolMock(toolName: string) {
const ToolMock = vi.fn();
Expand Down Expand Up @@ -144,6 +145,31 @@ vi.mock('node:fs', async (importOriginal) => {
};
});

vi.mock('../memory/metadata-migration.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../memory/metadata-migration.js')>()),
scanMemoryMetadataCorpusStatus: vi.fn().mockResolvedValue({
ready: false,
revision: 'legacy-revision',
files: 1,
legacyFiles: 1,
legacyByScope: { project: 1, user: 0, team: 0 },
}),
}));

vi.mock('../memory/scan.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../memory/scan.js')>()),
scanAutoMemorySnapshot: vi.fn().mockResolvedValue({
docs: [],
sourceStatus: {
requestedScopes: ['project', 'user'],
searchedScopes: ['project', 'user'],
unavailableScopes: [],
complete: true,
incompleteScopes: [],
},
}),
}));

// Mock dependencies that might be called during Config construction or createServerConfig
vi.mock('../tools/tool-registry', () => {
const ToolRegistryMock = vi.fn();
Expand Down Expand Up @@ -207,7 +233,10 @@ vi.mock('../memory/indexer.js', async (importActual) => ({
// Keep the real exports (notably TeamMemoryRootSecurityError, which the sync
// gate distinguishes via instanceof) and override only the rebuild.
...(await importActual<typeof import('../memory/indexer.js')>()),
rebuildAutoMemoryIndexAtRoot: vi.fn().mockResolvedValue(null),
rebuildManagedAutoMemoryIndex: vi.fn().mockResolvedValue(null),
rebuildTeamAutoMemoryIndex: vi.fn().mockResolvedValue(null),
rebuildUserAutoMemoryIndex: vi.fn().mockResolvedValue(null),
}));
vi.mock('../memory/team-memory-sync.js', () => ({
syncTeamMemory: vi
Expand Down Expand Up @@ -532,6 +561,13 @@ describe('Server Config (config.ts)', () => {
beforeEach(() => {
// Reset mocks if necessary
vi.clearAllMocks();
vi.mocked(scanMemoryMetadataCorpusStatus).mockResolvedValue({
ready: false,
revision: 'legacy-revision',
files: 1,
legacyFiles: 1,
legacyByScope: { project: 1, user: 0, team: 0 },
});
mockAutoMemoryInode = 1;
for (const envName of MEMORY_PRESSURE_ENV_KEYS) {
delete process.env[envName];
Expand Down Expand Up @@ -8693,6 +8729,26 @@ describe('Server Config (config.ts)', () => {
ToolNames.GET_GOAL,
ToolNames.UPDATE_GOAL,
]);
expect(
(registerToolMock as Mock).mock.calls.map((call) => call[0]),
).not.toContain(ToolNames.SEARCH_MEMORY);
});

it('should register structured memory tools in the normal tool registry', async () => {
const config = new Config(baseParams);
await config.initialize();

const registerToolMock = (
(await vi.importMock('../tools/tool-registry')) as {
ToolRegistry: { prototype: { registerFactory: Mock } };
}
).ToolRegistry.prototype.registerFactory;

const registeredNames = (registerToolMock as Mock).mock.calls.map(
(call) => call[0],
);
expect(registeredNames).toContain(ToolNames.SEARCH_MEMORY);
expect(registeredNames).toContain(ToolNames.MANAGE_MEMORY);
});

it('registers structured_output in bare mode when jsonSchema is set', async () => {
Expand Down
Loading
Loading