basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2026 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6 7import { beforeEach, describe, expect, it, vi } from 'vitest';8import type { Config } from '../config/config.js';9import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js';10import { scanAutoMemoryTopicDocuments } from './scan.js';11import { getAutoMemoryRoot, getUserAutoMemoryRoot } from './paths.js';12import { runForkedAgent, getCacheSafeParams } from '../utils/forkedAgent.js';13import { ToolNames } from '../tools/tool-names.js';14 15vi.mock('./scan.js', async (importOriginal) => {16 const actual = await importOriginal<typeof import('./scan.js')>();17 return {18 ...actual,19 scanAutoMemoryTopicDocuments: vi.fn(),20 // Explicit mock so the production scan does not silently fall through21 // to the real filesystem (it would only "work" because /tmp/user-memory22 // doesn't exist and listMarkdownFiles swallows ENOENT). Each test that23 // cares about user docs sets a mockReturnValue.24 scanUserAutoMemoryTopicDocuments: vi.fn().mockResolvedValue([]),25 };26});27 28vi.mock('./paths.js', async (importOriginal) => {29 const actual = await importOriginal<typeof import('./paths.js')>();30 return {31 ...actual,32 getAutoMemoryRoot: vi.fn().mockReturnValue('/tmp/auto-memory'),33 getUserAutoMemoryRoot: vi.fn().mockReturnValue('/tmp/user-memory'),34 };35});36 37vi.mock('../utils/forkedAgent.js', () => ({38 runForkedAgent: vi.fn(),39 getCacheSafeParams: vi.fn(),40}));41 42describe('runAutoMemoryExtractionByAgent', () => {43 const mockConfig = {44 getSessionId: vi.fn().mockReturnValue('session-1'),45 getModel: vi.fn().mockReturnValue('qwen3-coder-plus'),46 getApprovalMode: vi.fn(),47 } as unknown as Config;48 49 beforeEach(() => {50 vi.clearAllMocks();51 vi.mocked(getCacheSafeParams).mockReturnValue({52 generationConfig: {},53 history: [54 { role: 'user', parts: [{ text: 'I prefer terse responses.' }] },55 { role: 'model', parts: [{ text: 'Understood.' }] },56 ],57 model: 'qwen3-coder-plus',58 version: 1,59 });60 vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue([61 {62 type: 'user',63 filePath: '/tmp/auto-memory/user/prefs.md',64 relativePath: 'user/prefs.md',65 filename: 'prefs.md',66 title: 'User Memory',67 description: 'User preferences',68 body: '- Existing terse preference.',69 mtimeMs: 1,70 },71 ]);72 });73 74 it('derives touchedTopics from filesTouched and returns systemMessage', async () => {75 vi.mocked(runForkedAgent).mockResolvedValue({76 status: 'completed',77 finalText: '',78 filesTouched: ['/tmp/auto-memory/user/prefs.md'],79 filesWritten: ['/tmp/auto-memory/user/prefs.md'],80 });81 82 const result = await runAutoMemoryExtractionByAgent(mockConfig, '/tmp');83 84 expect(result).toEqual({85 touchedTopics: ['user'],86 touchedProjectScope: true,87 touchedUserScope: false,88 hasToolActivity: true,89 systemMessage: 'Managed auto-memory updated: user.md',90 });91 expect(runForkedAgent).toHaveBeenCalledWith(92 expect.objectContaining({93 tools: [94 'read_file',95 'grep_search',96 'glob',97 'list_directory',98 'run_shell_command',99 'write_file',100 'edit',101 ],102 maxTurns: 5,103 maxTimeMinutes: 2,104 }),105 );106 });107 108 it('returns empty touchedTopics when agent touches no files', async () => {109 vi.mocked(runForkedAgent).mockResolvedValue({110 status: 'completed',111 finalText: '',112 filesTouched: [],113 filesWritten: [],114 });115 116 const result = await runAutoMemoryExtractionByAgent(mockConfig, '/tmp');117 expect(result).toEqual({118 touchedTopics: [],119 touchedProjectScope: false,120 touchedUserScope: false,121 hasToolActivity: false,122 systemMessage: undefined,123 });124 });125 126 it('uses a scoped config that allows shell and denies outside writes', async () => {127 vi.mocked(runForkedAgent).mockResolvedValue({128 status: 'completed',129 finalText: '',130 filesTouched: [],131 });132 133 await runAutoMemoryExtractionByAgent(mockConfig, '/tmp');134 135 const call = vi.mocked(runForkedAgent).mock.calls[0]?.[0];136 const permissionManager = call?.config.getPermissionManager?.();137 expect(permissionManager).toBeDefined();138 expect(await permissionManager!.isToolEnabled(ToolNames.SHELL)).toBe(true);139 expect(140 permissionManager!.findMatchingDenyRule({141 toolName: ToolNames.WRITE_FILE,142 filePath: '/tmp/outside.md',143 }),144 ).toBe(145 'ManagedAutoMemory(write_file: only within /tmp/user-memory or /tmp/auto-memory)',146 );147 expect(148 await permissionManager!.evaluate({149 toolName: ToolNames.WRITE_FILE,150 filePath: '/tmp/outside.md',151 }),152 ).toBe('deny');153 });154 155 it('throws when getCacheSafeParams returns null', async () => {156 vi.mocked(getCacheSafeParams).mockReturnValue(null);157 await expect(158 runAutoMemoryExtractionByAgent(mockConfig, '/tmp'),159 ).rejects.toThrow('no cache-safe params');160 });161 162 it('throws when the agent fails to complete', async () => {163 vi.mocked(runForkedAgent).mockResolvedValue({164 status: 'failed',165 terminateReason: 'timeout',166 filesTouched: [],167 });168 169 await expect(170 runAutoMemoryExtractionByAgent(mockConfig, '/tmp/project'),171 ).rejects.toThrow('timeout');172 });173 174 it('ignores non-memory file paths in filesTouched', async () => {175 vi.mocked(runForkedAgent).mockResolvedValue({176 status: 'completed',177 finalText: '',178 filesTouched: [179 '/tmp/auto-memory/project/arch.md',180 '/tmp/auto-memory/reference/api.md',181 '/tmp/some/other/file.ts',182 ],183 filesWritten: [184 '/tmp/auto-memory/project/arch.md',185 '/tmp/auto-memory/reference/api.md',186 '/tmp/some/other/file.ts',187 ],188 });189 190 const result = await runAutoMemoryExtractionByAgent(mockConfig, '/tmp');191 expect(result.touchedTopics).toEqual(192 expect.arrayContaining(['project', 'reference']),193 );194 expect(result.touchedTopics).not.toContain('user');195 expect(result.touchedProjectScope).toBe(true);196 expect(result.touchedUserScope).toBe(false);197 });198 199 it('attributes user-rooted writes to the user scope (not project)', async () => {200 vi.mocked(runForkedAgent).mockResolvedValue({201 status: 'completed',202 finalText: '',203 filesTouched: [204 '/tmp/user-memory/user/role.md',205 '/tmp/user-memory/feedback/terse.md',206 ],207 filesWritten: [208 '/tmp/user-memory/user/role.md',209 '/tmp/user-memory/feedback/terse.md',210 ],211 });212 213 const result = await runAutoMemoryExtractionByAgent(mockConfig, '/tmp');214 expect(result.touchedTopics).toEqual(215 expect.arrayContaining(['user', 'feedback']),216 );217 expect(result.touchedUserScope).toBe(true);218 expect(result.touchedProjectScope).toBe(false);219 });220 221 it('classifies file paths when the root is backslash-native (Windows) but agent reports forward slashes', async () => {222 // On Windows the roots returned by getAutoMemoryRoot/getUserAutoMemoryRoot223 // are backslash-separated (`C:\Users\foo\...\memory`). The model's tool224 // calls (and the writes the agent reports as `filesTouched`) commonly225 // come back forward-slash-normalized. The classification must succeed in226 // that case — otherwise user-scope writes silently fail to rebuild the227 // index on Windows.228 //229 // sticky mockReturnValue (not Once) — the production code calls each230 // helper twice per extraction (prompt builder + touched-topics231 // classifier) so a Once-mock only covers the first call. Restored232 // below to keep subsequent tests on the suite's POSIX defaults.233 vi.mocked(getAutoMemoryRoot).mockReturnValue(234 'C:\\Users\\foo\\.qwen\\projects\\proj\\memory',235 );236 vi.mocked(getUserAutoMemoryRoot).mockReturnValue(237 'C:\\Users\\foo\\.qwen\\memories',238 );239 vi.mocked(runForkedAgent).mockResolvedValue({240 status: 'completed',241 finalText: '',242 filesTouched: [243 'C:/Users/foo/.qwen/projects/proj/memory/project/release.md',244 'C:/Users/foo/.qwen/memories/user/role.md',245 ],246 filesWritten: [247 'C:/Users/foo/.qwen/projects/proj/memory/project/release.md',248 'C:/Users/foo/.qwen/memories/user/role.md',249 ],250 });251 252 try {253 const result = await runAutoMemoryExtractionByAgent(mockConfig, '/tmp');254 expect(result.touchedTopics).toEqual(255 expect.arrayContaining(['project', 'user']),256 );257 expect(result.touchedProjectScope).toBe(true);258 expect(result.touchedUserScope).toBe(true);259 } finally {260 vi.mocked(getAutoMemoryRoot).mockReturnValue('/tmp/auto-memory');261 vi.mocked(getUserAutoMemoryRoot).mockReturnValue('/tmp/user-memory');262 }263 });264 265 it('classifies file paths regardless of which separator the agent reported', async () => {266 // Roots come back from the mocked getAutoMemoryRoot/getUserAutoMemoryRoot267 // as POSIX paths (`/tmp/...`). The agent's filesTouched may use either268 // separator on Windows hosts — the check must accept both.269 vi.mocked(runForkedAgent).mockResolvedValue({270 status: 'completed',271 finalText: '',272 filesTouched: [273 '/tmp/auto-memory\\project\\arch.md',274 '/tmp/user-memory\\user\\role.md',275 ],276 filesWritten: [277 '/tmp/auto-memory\\project\\arch.md',278 '/tmp/user-memory\\user\\role.md',279 ],280 });281 282 const result = await runAutoMemoryExtractionByAgent(mockConfig, '/tmp');283 expect(result.touchedTopics).toEqual(284 expect.arrayContaining(['project', 'user']),285 );286 expect(result.touchedProjectScope).toBe(true);287 expect(result.touchedUserScope).toBe(true);288 });289 290 it('rejects sibling directories that share a root prefix (no startsWith collision)', async () => {291 // getAutoMemoryRoot mocked → /tmp/auto-memory.292 // A path inside /tmp/auto-memory-other/ shares the string prefix but is293 // a different directory entirely; the trailing-separator guard must keep294 // it out of both scopes.295 vi.mocked(runForkedAgent).mockResolvedValue({296 status: 'completed',297 finalText: '',298 filesTouched: [299 '/tmp/auto-memory-other/user/x.md',300 '/tmp/user-memory-backup/user/y.md',301 ],302 });303 304 const result = await runAutoMemoryExtractionByAgent(mockConfig, '/tmp');305 expect(result.touchedTopics).toEqual([]);306 expect(result.touchedProjectScope).toBe(false);307 expect(result.touchedUserScope).toBe(false);308 });309 310 it('reports both scopes when the agent writes to both roots in one run', async () => {311 vi.mocked(runForkedAgent).mockResolvedValue({312 status: 'completed',313 finalText: '',314 filesTouched: [315 '/tmp/user-memory/user/role.md',316 '/tmp/auto-memory/project/release.md',317 ],318 filesWritten: [319 '/tmp/user-memory/user/role.md',320 '/tmp/auto-memory/project/release.md',321 ],322 });323 324 const result = await runAutoMemoryExtractionByAgent(mockConfig, '/tmp');325 expect(result.touchedTopics).toEqual(326 expect.arrayContaining(['user', 'project']),327 );328 expect(result.touchedProjectScope).toBe(true);329 expect(result.touchedUserScope).toBe(true);330 });331});332 