basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import * as fs from 'node:fs/promises';8import * as os from 'node:os';9import * as path from 'node:path';10import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';11import type { Config } from '../config/config.js';12import type { Content } from '@google/genai';13import { getAutoMemoryExtractCursorPath } from './paths.js';14import { runAutoMemoryExtract } from './extract.js';15import { runAutoMemoryExtractionByAgent } from './extractionAgentPlanner.js';16import { ensureAutoMemoryScaffold } from './store.js';17import {18 rebuildManagedAutoMemoryIndex,19 rebuildUserAutoMemoryIndex,20} from './indexer.js';21 22vi.mock('./extractionAgentPlanner.js', () => ({23 runAutoMemoryExtractionByAgent: vi.fn(),24}));25 26vi.mock('./indexer.js', () => ({27 rebuildManagedAutoMemoryIndex: vi.fn().mockResolvedValue(''),28 rebuildUserAutoMemoryIndex: vi.fn().mockResolvedValue(''),29}));30 31describe('auto-memory extraction', () => {32 let tempDir: string;33 let projectRoot: string;34 let mockConfig: Config;35 36 beforeEach(async () => {37 tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-extract-'));38 projectRoot = path.join(tempDir, 'project');39 await fs.mkdir(projectRoot, { recursive: true });40 await ensureAutoMemoryScaffold(projectRoot);41 mockConfig = {42 getSessionId: vi.fn().mockReturnValue('session-1'),43 getModel: vi.fn().mockReturnValue('qwen3-coder-plus'),44 } as unknown as Config;45 vi.clearAllMocks();46 });47 48 afterEach(async () => {49 await fs.rm(tempDir, {50 recursive: true,51 force: true,52 maxRetries: 3,53 retryDelay: 10,54 });55 });56 57 it('updates cursor and avoids duplicate writes for repeated extraction', async () => {58 vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({59 touchedTopics: [],60 touchedProjectScope: false,61 touchedUserScope: false,62 hasToolActivity: true,63 systemMessage: undefined,64 });65 66 const history = [67 { role: 'user', parts: [{ text: 'I prefer terse responses.' }] },68 { role: 'model', parts: [{ text: 'Understood.' }] },69 ];70 71 const first = await runAutoMemoryExtract({72 projectRoot,73 sessionId: 'session-1',74 config: mockConfig,75 history: [...history],76 });77 const second = await runAutoMemoryExtract({78 projectRoot,79 sessionId: 'session-1',80 config: mockConfig,81 history: [...history],82 });83 84 expect(first.touchedTopics).toEqual([]);85 expect(second.touchedTopics).toEqual([]);86 87 const cursor = JSON.parse(88 await fs.readFile(getAutoMemoryExtractCursorPath(projectRoot), 'utf-8'),89 ) as { processedOffset: number; sessionId: string };90 91 expect(cursor.sessionId).toBe('session-1');92 expect(cursor.processedOffset).toBe(2);93 });94 95 it('throws when config is missing because heuristic fallback was removed', async () => {96 await expect(97 runAutoMemoryExtract({98 projectRoot,99 sessionId: 'session-1',100 history: [101 { role: 'user', parts: [{ text: 'I prefer terse responses.' }] },102 ],103 }),104 ).rejects.toThrow('Managed auto-memory extraction requires config');105 });106 107 describe('rebuild failure isolation (asymmetric)', () => {108 const newHistory = [109 { role: 'user' as const, parts: [{ text: 'I prefer terse responses.' }] },110 ];111 112 async function readCursor() {113 return JSON.parse(114 await fs.readFile(getAutoMemoryExtractCursorPath(projectRoot), 'utf-8'),115 ) as { processedOffset?: number; sessionId?: string };116 }117 118 it('project-scope rebuild failure bubbles up so the cursor is NOT advanced (retry on next session)', async () => {119 // Pre-PR Promise.all behaviour: a project-level rebuild failure threw,120 // the cursor never advanced, and the same slice was re-extracted on121 // the next session — that durability guarantee is the whole point of122 // the cursor. The user-level layer must isolate its OWN failures, but123 // it cannot weaken the project-level retry contract.124 const cursorBefore = await readCursor();125 vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({126 touchedTopics: ['user'],127 touchedProjectScope: true,128 touchedUserScope: false,129 hasToolActivity: true,130 systemMessage: undefined,131 });132 vi.mocked(rebuildManagedAutoMemoryIndex).mockRejectedValueOnce(133 new Error('EACCES: project memory index write failed'),134 );135 136 await expect(137 runAutoMemoryExtract({138 projectRoot,139 sessionId: 'session-1',140 config: mockConfig,141 history: [...newHistory],142 }),143 ).rejects.toThrow('EACCES: project memory index write failed');144 145 const cursorAfter = await readCursor();146 expect(cursorAfter).toEqual(cursorBefore);147 });148 149 it('user-scope rebuild failure is logged and swallowed; project rebuild + cursor advance still happen', async () => {150 // User-level memory is best-effort: a read-only `~/.qwen/memories/`151 // must not prevent the project layer from making progress.152 vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({153 touchedTopics: ['user'],154 touchedProjectScope: true,155 touchedUserScope: true,156 hasToolActivity: true,157 systemMessage: undefined,158 });159 vi.mocked(rebuildManagedAutoMemoryIndex).mockResolvedValueOnce('');160 vi.mocked(rebuildUserAutoMemoryIndex).mockRejectedValueOnce(161 new Error('EACCES: user memory index write failed'),162 );163 164 await expect(165 runAutoMemoryExtract({166 projectRoot,167 sessionId: 'session-1',168 config: mockConfig,169 history: [...newHistory],170 }),171 ).resolves.toBeDefined();172 173 expect(rebuildManagedAutoMemoryIndex).toHaveBeenCalledTimes(1);174 expect(rebuildUserAutoMemoryIndex).toHaveBeenCalledTimes(1);175 176 const cursor = await readCursor();177 expect(cursor.sessionId).toBe('session-1');178 expect(cursor.processedOffset).toBe(1);179 });180 181 it('both rebuilds run in parallel when both scopes are touched', async () => {182 vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({183 touchedTopics: ['user', 'project'],184 touchedProjectScope: true,185 touchedUserScope: true,186 hasToolActivity: true,187 systemMessage: undefined,188 });189 190 await runAutoMemoryExtract({191 projectRoot,192 sessionId: 'session-1',193 config: mockConfig,194 history: [...newHistory],195 });196 197 expect(rebuildManagedAutoMemoryIndex).toHaveBeenCalledTimes(1);198 expect(rebuildUserAutoMemoryIndex).toHaveBeenCalledTimes(1);199 });200 201 it('defensive fallback rebuilds the project index when neither scope flag is set but topics were touched', async () => {202 // Mirrors the planner-was-stale-during-rollout safety net in extract.ts.203 vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({204 touchedTopics: ['user'],205 touchedProjectScope: false,206 touchedUserScope: false,207 hasToolActivity: true,208 systemMessage: undefined,209 });210 211 await runAutoMemoryExtract({212 projectRoot,213 sessionId: 'session-1',214 config: mockConfig,215 history: [...newHistory],216 });217 218 expect(rebuildManagedAutoMemoryIndex).toHaveBeenCalledTimes(1);219 expect(rebuildUserAutoMemoryIndex).not.toHaveBeenCalled();220 });221 });222 223 describe('#5147 OOM regression', () => {224 /**225 * A1: cursor-first — runAutoMemoryExtract only processes the unread226 * portion of history. The first call processes all messages; the second227 * call (with only a few new messages appended) should NOT reprocess228 * the already-processed prefix.229 */230 it('only processes unread messages via cursor-first ordering', async () => {231 vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({232 touchedTopics: ['user'],233 touchedProjectScope: true,234 touchedUserScope: false,235 hasToolActivity: true,236 systemMessage: undefined,237 });238 239 // Build 20 messages (10 turns of user+model)240 const history: Content[] = [];241 for (let i = 0; i < 20; i++) {242 history.push({243 role: i % 2 === 0 ? 'user' : 'model',244 parts: [245 { text: `[MSG${i}] `.padEnd(16, '-') + `content for message ${i}` },246 ],247 });248 }249 250 // First extract: processes all 20 messages251 const first = await runAutoMemoryExtract({252 projectRoot,253 sessionId: 'session-1',254 config: mockConfig,255 history: [...history],256 });257 expect(first.cursor.processedOffset).toBe(20);258 expect(runAutoMemoryExtractionByAgent).toHaveBeenCalledTimes(1);259 260 // Add 2 new messages (1 turn)261 history.push(262 { role: 'user', parts: [{ text: 'new user question?' }] },263 { role: 'model', parts: [{ text: 'new assistant answer.' }] },264 );265 266 // Second extract: should detect only the 2 new messages267 const agentCallsBefore = vi.mocked(runAutoMemoryExtractionByAgent).mock268 .calls.length;269 const second = await runAutoMemoryExtract({270 projectRoot,271 sessionId: 'session-1',272 config: mockConfig,273 history: [...history],274 });275 276 // Fork agent should have been called again (new user message found)277 expect(vi.mocked(runAutoMemoryExtractionByAgent).mock.calls.length).toBe(278 agentCallsBefore + 1,279 );280 // Cursor advances to full history length281 expect(second.cursor.processedOffset).toBe(22);282 });283 284 /**285 * A2: when the cursor is already at the end of history (no new user286 * messages), runAutoMemoryExtract skips without calling the fork agent.287 */288 it('skips extract when cursor is already up to date', async () => {289 vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({290 touchedTopics: [],291 touchedProjectScope: false,292 touchedUserScope: false,293 hasToolActivity: true,294 systemMessage: undefined,295 });296 297 const history: Content[] = [298 { role: 'user', parts: [{ text: 'hello' }] },299 { role: 'model', parts: [{ text: 'hi' }] },300 ];301 302 // First extract: cursor → 2303 await runAutoMemoryExtract({304 projectRoot,305 sessionId: 'session-1',306 config: mockConfig,307 history: [...history],308 });309 310 const agentCallsBefore = vi.mocked(runAutoMemoryExtractionByAgent).mock311 .calls.length;312 313 // Second extract with same 2 messages: no new user messages314 const result = await runAutoMemoryExtract({315 projectRoot,316 sessionId: 'session-1',317 config: mockConfig,318 history: [...history],319 });320 321 // Fork agent should NOT be called again322 expect(vi.mocked(runAutoMemoryExtractionByAgent).mock.calls.length).toBe(323 agentCallsBefore,324 );325 expect(result.touchedTopics).toEqual([]);326 expect(result.cursor.processedOffset).toBe(2);327 });328 329 /**330 * A3: a huge single message does not OOM the cursor scan. The cursor331 * path no longer stringifies history with the global whitespace regex;332 * it only does a bounded partToString().trim() on the unprocessed slice333 * to detect new user content. A 5MB message must be handled without the334 * old full-history .replace(/\s+/g) blow-up.335 */336 it('handles a huge single message without OOM in the cursor scan', async () => {337 vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({338 touchedTopics: ['user'],339 touchedProjectScope: true,340 touchedUserScope: false,341 hasToolActivity: true,342 systemMessage: undefined,343 });344 345 const hugeText = 'x '.repeat(2_500_000); // ~5MB with whitespace346 const result = await runAutoMemoryExtract({347 projectRoot,348 sessionId: 'session-1',349 config: mockConfig,350 history: [{ role: 'user', parts: [{ text: hugeText }] }],351 });352 353 // New user content detected → fork agent invoked, cursor advanced.354 expect(runAutoMemoryExtractionByAgent).toHaveBeenCalled();355 expect(result.cursor.processedOffset).toBe(1);356 });357 358 /**359 * A4: when the session ID changes between extracts, the cursor from the360 * old session is ignored, and the full history is treated as unprocessed.361 */362 it('reprocesses full history when session changes', async () => {363 vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({364 touchedTopics: ['user'],365 touchedProjectScope: true,366 touchedUserScope: false,367 hasToolActivity: true,368 systemMessage: undefined,369 });370 371 const history: Content[] = [372 { role: 'user', parts: [{ text: 'first session query' }] },373 { role: 'model', parts: [{ text: 'first session answer' }] },374 ];375 376 // Session 1: cursor advances to 2377 await runAutoMemoryExtract({378 projectRoot,379 sessionId: 'session-1',380 config: mockConfig,381 history: [...history],382 });383 384 const agentCallsBefore = vi.mocked(runAutoMemoryExtractionByAgent).mock385 .calls.length;386 387 // Session 2: cursor ignored, full history treated as unprocessed388 await runAutoMemoryExtract({389 projectRoot,390 sessionId: 'session-2',391 config: mockConfig,392 history: [...history],393 });394 395 // Fork agent called again (session changed, so messages are "new")396 expect(vi.mocked(runAutoMemoryExtractionByAgent).mock.calls.length).toBe(397 agentCallsBefore + 1,398 );399 });400 401 /**402 * A5: verify that cursor-first ordering prevents OOM by processing only403 * the unread portion. Constructs a large history where most messages404 * have already been processed, then verifies that the extract completes405 * without processing the full-history text through .replace().406 */407 it('avoids full-history regex replace when most messages are already processed', async () => {408 vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({409 touchedTopics: ['user'],410 touchedProjectScope: true,411 touchedUserScope: false,412 hasToolActivity: true,413 systemMessage: undefined,414 });415 416 // Build 50 messages, each with unique text to prevent string interning.417 const history: Content[] = [];418 for (let i = 0; i < 50; i++) {419 const prefix = `[MSG${i}] `.padEnd(16, '-');420 history.push({421 role: i % 2 === 0 ? 'user' : 'model',422 parts: [{ text: prefix + `${i}: the quick brown fox `.repeat(200) }],423 });424 }425 426 // Process all 50 in the first extract427 await runAutoMemoryExtract({428 projectRoot,429 sessionId: 'session-1',430 config: mockConfig,431 history: [...history],432 });433 434 // Add 2 more messages — only these need processing435 history.push(436 { role: 'user', parts: [{ text: 'final question?'.repeat(50) }] },437 { role: 'model', parts: [{ text: 'final answer.'.repeat(50) }] },438 );439 440 const agentCallsBefore = vi.mocked(runAutoMemoryExtractionByAgent).mock441 .calls.length;442 443 // This should complete without OOM — it only processes 2 messages,444 // not the full 52.445 const result = await runAutoMemoryExtract({446 projectRoot,447 sessionId: 'session-1',448 config: mockConfig,449 history: [...history],450 });451 452 expect(vi.mocked(runAutoMemoryExtractionByAgent).mock.calls.length).toBe(453 agentCallsBefore + 1,454 );455 expect(result.cursor.processedOffset).toBe(52);456 });457 458 /**459 * A6: the cursor scan must not count empty or whitespace-only user460 * messages as "new user content". The partToString().trim().length > 0461 * filter should cause the extract to be skipped, just like the old462 * buildTranscriptMessages().filter() did.463 */464 it('skips extract when unprocessed user messages are whitespace-only', async () => {465 const history: Content[] = [{ role: 'user', parts: [{ text: ' ' }] }];466 const result = await runAutoMemoryExtract({467 projectRoot,468 sessionId: 'session-1',469 config: mockConfig,470 history: [...history],471 });472 473 expect(runAutoMemoryExtractionByAgent).not.toHaveBeenCalled();474 expect(result.touchedTopics).toEqual([]);475 });476 477 it('skips extract when unprocessed user messages have empty parts', async () => {478 const history: Content[] = [{ role: 'user', parts: [] }];479 const result = await runAutoMemoryExtract({480 projectRoot,481 sessionId: 'session-1',482 config: mockConfig,483 history: [...history],484 });485 486 expect(runAutoMemoryExtractionByAgent).not.toHaveBeenCalled();487 expect(result.touchedTopics).toEqual([]);488 });489 490 /**491 * A7: when history shrinks between extract calls (e.g. compression492 * reduces 50 → 17 entries), the stored processedOffset (50) exceeds493 * history.length (17). The cursor-first logic must reset startOffset494 * to 0 rather than passing 50 to history.slice(), which would return495 * [] and permanently skip new messages.496 */497 it('re-scans full history when stored offset exceeds current length (compression)', async () => {498 vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({499 touchedTopics: ['user'],500 touchedProjectScope: true,501 touchedUserScope: false,502 hasToolActivity: true,503 systemMessage: undefined,504 });505 506 // First extract: 20 messages, cursor advances to 20.507 const fullHistory: Content[] = [];508 for (let i = 0; i < 20; i++) {509 fullHistory.push({510 role: i % 2 === 0 ? 'user' : 'model',511 parts: [{ text: `compression msg ${i}` }],512 });513 }514 await runAutoMemoryExtract({515 projectRoot,516 sessionId: 'session-1',517 config: mockConfig,518 history: [...fullHistory],519 });520 521 // Simulate compression: history shrinks from 20 to 5, but cursor522 // still says processedOffset = 20. Then a new user message is added.523 const compressedHistory = fullHistory.slice(0, 5);524 compressedHistory.push({525 role: 'user',526 parts: [{ text: 'new question after compression' }],527 });528 529 const agentCallsBefore = vi.mocked(runAutoMemoryExtractionByAgent).mock530 .calls.length;531 532 const result = await runAutoMemoryExtract({533 projectRoot,534 sessionId: 'session-1',535 config: mockConfig,536 history: [...compressedHistory], // 6 messages, cursor says 20537 });538 539 // The new user message must be detected — startOffset was clamped to 0540 // instead of using the stale 20 that exceeds history.length (6).541 expect(vi.mocked(runAutoMemoryExtractionByAgent).mock.calls.length).toBe(542 agentCallsBefore + 1,543 );544 expect(result.cursor.processedOffset).toBe(compressedHistory.length);545 });546 it('BUG #6311: should NOT advance cursor when agent makes zero tool calls (hallucination)', async () => {547 vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({548 touchedTopics: [],549 touchedProjectScope: false,550 touchedUserScope: false,551 hasToolActivity: false,552 systemMessage: undefined,553 });554 555 const history = [556 {557 role: 'user' as const,558 parts: [{ text: 'Remember that I prefer pnpm over npm.' }],559 },560 ];561 562 const result = await runAutoMemoryExtract({563 projectRoot,564 sessionId: 'session-1',565 config: mockConfig,566 history: [...history],567 });568 569 expect(result.cursor.processedOffset).toBe(0);570 });571 it('should advance cursor on legitimate noop (agent checked memory, found nothing new)', async () => {572 vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({573 touchedTopics: [],574 touchedProjectScope: false,575 touchedUserScope: false,576 hasToolActivity: true,577 systemMessage: undefined,578 });579 580 const history = [{ role: 'user' as const, parts: [{ text: 'hello' }] }];581 582 const result = await runAutoMemoryExtract({583 projectRoot,584 sessionId: 'session-1',585 config: mockConfig,586 history: [...history],587 });588 589 expect(result.cursor.processedOffset).toBe(1);590 });591 });592});593 