CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
extract.test.ts593 linesDownload Raw Back to memory
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 
basant307/AI_Governance_Project · CoolFace