mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
* refactor: modular context compaction architecture Split context compaction into focused modules: - contextBlockRegistry: single source of truth for block types - compactionUtils: shared pure functions for truncation/extraction - ChatHistoryCompactor: tool result compaction at memory save time - L2ContextCompactor: L3→L2 context compaction with previews Key changes: - Never compact selected_text (non-recoverable content) - Single consolidated re-fetch instruction at end of L2 context - Write-time compaction via memoryManager.saveContext() - Parse prior_context blocks in segment extraction - Backwards compatibility via re-exports Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use brace-balanced extraction for readNote JSON parsing The previous regex-based approach stopped at the first closing brace, truncating JSON payloads containing nested braces (e.g., code snippets). This caused JSON.parse to fail and skip compaction entirely. Now uses a proper brace-balanced parser that correctly handles: - Nested objects and arrays - Braces inside string literals - Escaped characters in strings Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: preserve all documents in localSearch compaction Previously, compactToolResultBlock used extractContentFromBlock which only extracts the first <content> match. For localSearch results with multiple <document> blocks, this caused all documents after the first to be lost during compaction. Now localSearch blocks are handled specially: - All documents are extracted and preserved - Each document keeps its title and path - Content is truncated to preview length - Output lists all documents with [[wikilink]] format Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: skip flaky composer integration tests Skip composer integration tests due to intermittent 503 errors from Gemini API causing CI failures. The tests depend on external API availability which is unreliable. TODO(@logancyang, @wenzhengjiang): Re-enable once composer is revamped. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: preserve all blocks when compacting multi-block L2 segments When URL context segments contain multiple concatenated <url_content> blocks (from processUrlList), the previous compaction only processed the first block. Now detects multiple blocks of the same type and compacts each one independently, preserving all URL context. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: preserve mixed XML block types during L2 compaction The previous fix only handled multiple blocks of the same type. When segments contain mixed block types (e.g., web_tab_context mixed with youtube_video_context from processContextWebTabs), blocks of other types were dropped. Now uses CONTEXT_BLOCK_TYPES registry to match all known block types and compacts each one independently, preserving all context regardless of block type mix. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
187 lines
6.4 KiB
TypeScript
187 lines
6.4 KiB
TypeScript
import { DEFAULT_SYSTEM_PROMPT, COMPOSER_OUTPUT_INSTRUCTIONS } from "../constants";
|
|
import * as dotenv from "dotenv";
|
|
import { jest } from "@jest/globals";
|
|
import {
|
|
GoogleGenerativeAI,
|
|
HarmCategory,
|
|
HarmBlockThreshold,
|
|
GenerativeModel,
|
|
} from "@google/generative-ai";
|
|
|
|
// Add global fetch polyfill for Node.js environments
|
|
import fetch, { Headers, Request, Response } from "node-fetch";
|
|
if (!globalThis.fetch) {
|
|
globalThis.fetch = fetch as any;
|
|
globalThis.Headers = Headers as any;
|
|
globalThis.Request = Request as any;
|
|
globalThis.Response = Response as any;
|
|
}
|
|
|
|
// Load environment variables from .env.test
|
|
dotenv.config({ path: ".env.test" });
|
|
|
|
// Test data
|
|
const atom_note = `Atoms are the basic particles of the chemical elements. An atom consists of a nucleus of protons and generally neutrons, surrounded by an electromagnetically bound swarm of electrons. The chemical elements are distinguished from each other by the number of protons that are in their atoms. For example, any atom that contains 11 protons is sodium, and any atom that contains 29 protons is copper. Atoms with the same number of protons but a different number of neutrons are called isotopes of the same element.
|
|
|
|
Atoms are extremely small, typically around 100 picometers across. A human hair is about a million carbon atoms wide. Atoms are smaller than the shortest wavelength of visible light, which means humans cannot see atoms with conventional microscopes. They are so small that accurately predicting their behavior using classical physics is not possible due to quantum effects.`;
|
|
|
|
// Increase test timeout to 30 seconds
|
|
jest.setTimeout(30000);
|
|
|
|
jest.mock("../encryptionService", () => ({
|
|
getDecryptedKey: jest.fn().mockImplementation((key) => Promise.resolve(key)),
|
|
}));
|
|
|
|
// TODO(@logancyang, @wenzhengjiang): Re-enable once composer is revamped.
|
|
// Currently skipped due to flaky 503 errors from Gemini API causing CI failures.
|
|
describe.skip("Composer Instructions - Integration Tests", () => {
|
|
let genAI: GoogleGenerativeAI;
|
|
let model: GenerativeModel;
|
|
|
|
beforeAll(() => {
|
|
// Log for debugging
|
|
console.log("Starting tests, API key available:", !!process.env.GEMINI_API_KEY);
|
|
|
|
// Fail tests if no API key is available
|
|
if (!process.env.GEMINI_API_KEY) {
|
|
throw new Error(
|
|
"GEMINI_API_KEY not found in .env.test - integration tests require a valid API key"
|
|
);
|
|
}
|
|
|
|
// Initialize Google Generative AI client directly with the SDK
|
|
genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
|
|
model = genAI.getGenerativeModel({
|
|
model: "gemini-2.5-flash-lite",
|
|
generationConfig: {
|
|
temperature: 0.1,
|
|
maxOutputTokens: 5000,
|
|
},
|
|
systemInstruction: DEFAULT_SYSTEM_PROMPT,
|
|
safetySettings: [
|
|
{
|
|
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
|
|
threshold: HarmBlockThreshold.BLOCK_NONE,
|
|
},
|
|
{
|
|
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
|
threshold: HarmBlockThreshold.BLOCK_NONE,
|
|
},
|
|
{
|
|
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
|
|
threshold: HarmBlockThreshold.BLOCK_NONE,
|
|
},
|
|
{
|
|
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
|
threshold: HarmBlockThreshold.BLOCK_NONE,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
// Helper function to run a test with a given prompt and check for writeToFile blocks
|
|
const testComposerResponse = async (
|
|
testName: string,
|
|
userPrompt: string,
|
|
expectedBlocks: number = 1
|
|
) => {
|
|
test(testName, async () => {
|
|
try {
|
|
// Create chat session and send message
|
|
const chat = model.startChat();
|
|
const result = await chat.sendMessage(
|
|
userPrompt + "\n\n<output_format>\n" + COMPOSER_OUTPUT_INSTRUCTIONS + "\n</output_format>"
|
|
);
|
|
const content = result.response.text();
|
|
// Log preview for inspection
|
|
console.log(`${testName} - Response preview:`, content);
|
|
|
|
if (expectedBlocks == 0) {
|
|
expect(content).not.toContain("<writeToFile>");
|
|
return;
|
|
} else {
|
|
// Extract writeToFile blocks
|
|
const writeToFileRegex =
|
|
/<writeToFile>\s*<path>(.*?)<\/path>\s*<content>([\s\S]*?)<\/content>\s*<\/writeToFile>/g;
|
|
const matches = [...content.matchAll(writeToFileRegex)];
|
|
|
|
expect(matches.length).toBe(expectedBlocks);
|
|
|
|
// Validate each block
|
|
matches.forEach((match, index) => {
|
|
const path = match[1].trim();
|
|
const contentStr = match[2].trim();
|
|
|
|
// Check path ends with .md or .canvas
|
|
expect(path).toMatch(/\.(md|canvas)$/);
|
|
|
|
// For canvas files, validate JSON structure
|
|
if (path.endsWith(".canvas")) {
|
|
try {
|
|
const canvasJson = JSON.parse(contentStr);
|
|
expect(canvasJson).toHaveProperty("nodes");
|
|
expect(Array.isArray(canvasJson.nodes)).toBe(true);
|
|
if (canvasJson.edges) {
|
|
expect(Array.isArray(canvasJson.edges)).toBe(true);
|
|
}
|
|
} catch (e) {
|
|
throw new Error(`Invalid canvas JSON in block: ${e.message}`);
|
|
}
|
|
} else {
|
|
// For markdown files, just check it has content
|
|
expect(contentStr.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error(`${testName} error:`, error);
|
|
throw error;
|
|
}
|
|
});
|
|
};
|
|
|
|
// Run tests with different prompts
|
|
testComposerResponse(
|
|
"Composer: create a new note",
|
|
"@composer Create a new note about climate change?"
|
|
);
|
|
|
|
testComposerResponse(
|
|
"Composer: add content to a note",
|
|
`Add a tl;dr to [[atom]].
|
|
|
|
Title: [[atom]]
|
|
Path: atom.md
|
|
${atom_note}`
|
|
);
|
|
|
|
testComposerResponse(
|
|
"Composer: rewrite a note",
|
|
`@composer Rewrite the note [[atom]] to be more concise.
|
|
|
|
Title: [[atom]]
|
|
Path: atom.md
|
|
${atom_note}`
|
|
);
|
|
|
|
testComposerResponse(
|
|
"Composer: remove content from a note",
|
|
`@composer Remove the second paragraph.
|
|
|
|
Title: [[atom]]
|
|
Path: atom.md
|
|
${atom_note}`
|
|
);
|
|
|
|
testComposerResponse(
|
|
"Composer: update multiple notes",
|
|
"@composer Create two notes on the topic of Earth and Mars separately",
|
|
2
|
|
);
|
|
|
|
testComposerResponse(
|
|
"Composer: create a canvas",
|
|
"@composer Create a sample canvas with 3 nodes and 1 edge",
|
|
1
|
|
);
|
|
});
|