mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Backfill condensedUserMessage
This commit is contained in:
parent
3cd9eb5186
commit
2ff8020002
2 changed files with 128 additions and 52 deletions
|
|
@ -12,12 +12,17 @@ jest.mock("@/constants", () => ({
|
|||
USER_SENDER: "user",
|
||||
}));
|
||||
|
||||
jest.mock("@/utils", () => ({
|
||||
ensureFolderExists: jest.fn(),
|
||||
}));
|
||||
|
||||
import { UserMemoryManager } from "./UserMemoryManager";
|
||||
import { App, TFile, Vault } from "obsidian";
|
||||
import { ChatMessage } from "@/types/message";
|
||||
import { logInfo, logError } from "@/logger";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { USER_SENDER } from "@/constants";
|
||||
import { ensureFolderExists } from "@/utils";
|
||||
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
|
||||
import { AIMessageChunk } from "@langchain/core/messages";
|
||||
|
||||
|
|
@ -63,6 +68,9 @@ describe("UserMemoryManager", () => {
|
|||
vault: mockVault,
|
||||
} as any;
|
||||
|
||||
// Reset ensureFolderExists mock
|
||||
(ensureFolderExists as jest.Mock).mockClear();
|
||||
|
||||
// Mock chat model
|
||||
mockChatModel = {
|
||||
invoke: jest.fn(),
|
||||
|
|
@ -104,39 +112,6 @@ describe("UserMemoryManager", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("should create nested memory folders recursively", async () => {
|
||||
const messages = [createMockMessage("1", "test user message")];
|
||||
|
||||
// Set nested folder path in settings
|
||||
mockSettings.memoryFolderName = "deep/nested/memory/folder";
|
||||
|
||||
// Mock folders don't exist
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(null);
|
||||
|
||||
// Mock LLM responses
|
||||
const mockResponse1 = new AIMessageChunk({ content: "Test Conversation Title" });
|
||||
const mockResponse2 = new AIMessageChunk({ content: "NONE" });
|
||||
mockChatModel.invoke.mockResolvedValueOnce(mockResponse1);
|
||||
mockChatModel.invoke.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
userMemoryManager.updateUserMemory(messages, mockChatModel);
|
||||
|
||||
// Wait for async operation to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Verify all nested folders were created
|
||||
expect(mockVault.createFolder).toHaveBeenCalledWith("deep");
|
||||
expect(mockVault.createFolder).toHaveBeenCalledWith("deep/nested");
|
||||
expect(mockVault.createFolder).toHaveBeenCalledWith("deep/nested/memory");
|
||||
expect(mockVault.createFolder).toHaveBeenCalledWith("deep/nested/memory/folder");
|
||||
|
||||
// Verify file creation in nested path
|
||||
expect(mockVault.create).toHaveBeenCalledWith(
|
||||
"deep/nested/memory/folder/Recent Conversations.md",
|
||||
expect.stringContaining("## Test Conversation Title")
|
||||
);
|
||||
});
|
||||
|
||||
it("should complete end-to-end memory update with existing file", async () => {
|
||||
// Setup: Create test messages simulating a real conversation with enough content for key conclusions
|
||||
const messages = [
|
||||
|
|
@ -178,11 +153,11 @@ describe("UserMemoryManager", () => {
|
|||
|
||||
const mockMemoryFile = createMockTFile("copilot/memory/Recent Conversations.md");
|
||||
|
||||
// Mock vault responses for folder and file existence
|
||||
const mockFolder = { path: "copilot/memory", name: "memory" } as any;
|
||||
mockVault.getAbstractFileByPath
|
||||
.mockReturnValueOnce(mockFolder) // Folder exists
|
||||
.mockReturnValueOnce(mockMemoryFile); // File exists
|
||||
// Mock ensureFolderExists to resolve successfully
|
||||
(ensureFolderExists as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
// Mock app instance for file operations
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(mockMemoryFile);
|
||||
|
||||
// Mock reading existing file content
|
||||
mockVault.read.mockResolvedValue(existingMemoryContent);
|
||||
|
|
@ -197,11 +172,8 @@ describe("UserMemoryManager", () => {
|
|||
.mockResolvedValueOnce(mockTitleResponse)
|
||||
.mockResolvedValueOnce(mockConclusionResponse);
|
||||
|
||||
// Execute the updateUserMemory function
|
||||
userMemoryManager.updateUserMemory(messages, mockChatModel);
|
||||
|
||||
// Wait for async operation to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
// Execute the updateMemory function directly to ensure proper awaiting
|
||||
await (userMemoryManager as any).updateMemory(messages, mockChatModel);
|
||||
|
||||
// Verify the end result: file was modified with new conversation
|
||||
const modifyCall = mockVault.modify.mock.calls[0];
|
||||
|
|
@ -291,6 +263,80 @@ describe("UserMemoryManager", () => {
|
|||
// Verify no new file creation was needed since file already exists
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle missing condensed messages by creating them inline (race condition fix)", async () => {
|
||||
// Setup: Create messages without condensed messages to simulate race condition
|
||||
const messages = [
|
||||
createMockMessage("1", "How do I create daily notes?"),
|
||||
createMockMessage("2", "AI response about daily notes", "ai"),
|
||||
createMockMessage("3", "What about templates?"),
|
||||
];
|
||||
|
||||
// Remove condensed messages to simulate race condition
|
||||
delete messages[0].condensedUserMessage;
|
||||
delete messages[2].condensedUserMessage;
|
||||
|
||||
const mockMemoryFile = createMockTFile("copilot/memory/Recent Conversations.md");
|
||||
const existingContent = "";
|
||||
|
||||
// Mock ensureFolderExists and file operations
|
||||
(ensureFolderExists as jest.Mock).mockResolvedValue(undefined);
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(mockMemoryFile);
|
||||
mockVault.read.mockResolvedValue(existingContent);
|
||||
|
||||
// Mock LLM responses
|
||||
const mockTitleResponse = new AIMessageChunk({ content: "Daily Notes Help" });
|
||||
const mockConclusionResponse = new AIMessageChunk({
|
||||
content: "- Daily notes can be automated with templates",
|
||||
});
|
||||
|
||||
// Setup condensed message creation (called inline for missing entries)
|
||||
const condensedMessage1 = "Asked about creating daily notes";
|
||||
const condensedMessage2 = "Inquired about template usage";
|
||||
|
||||
// Mock createCondensedMessage to return condensed versions
|
||||
const createCondensedMessageSpy = jest.spyOn(
|
||||
userMemoryManager as any,
|
||||
"createCondensedMessage"
|
||||
);
|
||||
|
||||
createCondensedMessageSpy.mockImplementation(async (message, model) => {
|
||||
if (message === "How do I create daily notes?") {
|
||||
return condensedMessage1;
|
||||
}
|
||||
if (message === "What about templates?") {
|
||||
return condensedMessage2;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
mockChatModel.invoke
|
||||
.mockResolvedValueOnce(mockTitleResponse)
|
||||
.mockResolvedValueOnce(mockConclusionResponse);
|
||||
|
||||
// Execute the updateMemory function
|
||||
await (userMemoryManager as any).updateMemory(messages, mockChatModel);
|
||||
|
||||
// Verify condensed messages were created inline for missing entries
|
||||
expect(createCondensedMessageSpy).toHaveBeenCalledTimes(2);
|
||||
expect(createCondensedMessageSpy).toHaveBeenCalledWith(
|
||||
"How do I create daily notes?",
|
||||
mockChatModel
|
||||
);
|
||||
expect(createCondensedMessageSpy).toHaveBeenCalledWith(
|
||||
"What about templates?",
|
||||
mockChatModel
|
||||
);
|
||||
|
||||
// Verify the final content includes the inline-created condensed messages
|
||||
const modifyCall = mockVault.modify.mock.calls[0];
|
||||
const actualContent = modifyCall[1];
|
||||
|
||||
expect(actualContent).toContain("Asked about creating daily notes");
|
||||
expect(actualContent).toContain("Inquired about template usage");
|
||||
|
||||
createCondensedMessageSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserMemoryPrompt", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { App, TFile } from "obsidian";
|
||||
import { ChatMessage } from "@/types/message";
|
||||
import { logInfo, logError } from "@/logger";
|
||||
import { logInfo, logError, logWarn } from "@/logger";
|
||||
import { USER_SENDER } from "@/constants";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { ensureFolderExists } from "@/utils";
|
||||
|
|
@ -196,12 +196,42 @@ Condense the user message into a single concise sentence while preserving intent
|
|||
): Promise<string> {
|
||||
const conversationTitle = await this.extractConversationTitle(messages, chatModel);
|
||||
const timestamp = new Date().toISOString().split(".")[0] + "Z"; // Remove milliseconds but keep Z for UTC
|
||||
const userMessageTexts = messages
|
||||
.filter(this.hasValidCondensedUserMessage.bind(this))
|
||||
.map((message) => {
|
||||
// Use condensed message if available
|
||||
return `- ${message.condensedUserMessage}`;
|
||||
});
|
||||
|
||||
// Process user messages and ensure condensed messages are available
|
||||
const userMessages = messages.filter((message) => message.sender === USER_SENDER);
|
||||
const userMessageTexts: string[] = [];
|
||||
|
||||
for (const message of userMessages) {
|
||||
let condensedText = message.condensedUserMessage;
|
||||
|
||||
// If condensed message is missing or invalid, create it inline to handle race condition
|
||||
if (
|
||||
!condensedText ||
|
||||
typeof condensedText !== "string" ||
|
||||
condensedText.trim().length === 0
|
||||
) {
|
||||
try {
|
||||
const newCondensedText = await this.createCondensedMessage(message.message, chatModel);
|
||||
if (newCondensedText) {
|
||||
condensedText = newCondensedText;
|
||||
logWarn(
|
||||
`[UserMemoryManager] Created inline condensed message for missing entry: "${condensedText}"`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logError(
|
||||
`[UserMemoryManager] Failed to create inline condensed message for "${message.message}":`,
|
||||
error
|
||||
);
|
||||
// Continue processing other messages even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
// Only include if we have valid condensed text
|
||||
if (condensedText && condensedText.trim().length > 0) {
|
||||
userMessageTexts.push(`- ${condensedText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate key conclusions if conversation is substantial enough
|
||||
const keyConclusionsText = await this.extractKeyConclusion(messages, chatModel);
|
||||
|
|
@ -429,10 +459,10 @@ Generate a title for the conversation:`;
|
|||
try {
|
||||
const response = await chatModel.invoke(messages_llm);
|
||||
const summary = response.content.toString().trim();
|
||||
return summary || "No summary";
|
||||
return summary || "Untitled Conversation";
|
||||
} catch (error) {
|
||||
logError("[UserMemoryManager] Failed to extract conversation summary:", error);
|
||||
return "No summary";
|
||||
return "Untitled Conversation";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue