feat(FN-2000): add memory auto-summarize settings and automation sync
- Add Memory section controls for enabling auto-summarize with threshold and cron schedule inputs - Wire ProjectEngine to sync auto-summarize automation on startup and when related settings change - Reuse a single startup settings snapshot when syncing insight extraction and auto-summarize automations - Add SettingsModal and ProjectEngine tests covering auto-summarize UI persistence and automation re-sync behavior
This commit is contained in:
@@ -30,12 +30,17 @@ const mockChatStore = {
|
||||
const mockAgentStore = {
|
||||
init: vi.fn(),
|
||||
getAgent: vi.fn(),
|
||||
listAgents: vi.fn(),
|
||||
};
|
||||
|
||||
function createChatManager(): ChatManager {
|
||||
return new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any);
|
||||
}
|
||||
|
||||
function createChatManagerWithoutAgentStore(): ChatManager {
|
||||
return new ChatManager(mockChatStore as any, "/tmp/test");
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ChatManager.sendMessage", () => {
|
||||
@@ -66,6 +71,17 @@ describe("ChatManager.sendMessage", () => {
|
||||
memory: "Remember to keep test coverage high.",
|
||||
instructionsText: "Keep replies focused.",
|
||||
});
|
||||
mockAgentStore.listAgents.mockResolvedValue([
|
||||
{
|
||||
id: "agent-001",
|
||||
name: "Avery",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
metadata: {},
|
||||
},
|
||||
]);
|
||||
|
||||
__setBuildAgentChatPrompt(async ({ agent, basePrompt }: any) => {
|
||||
return [
|
||||
@@ -81,6 +97,173 @@ describe("ChatManager.sendMessage", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("mention parsing and context", () => {
|
||||
it("parseMentions extracts known agent names from content", async () => {
|
||||
mockAgentStore.listAgents.mockResolvedValue([
|
||||
{
|
||||
id: "agent-001",
|
||||
name: "Alpha",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
metadata: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const chatManager = createChatManager();
|
||||
const mentions = await (chatManager as any).parseMentions("hello @Alpha how are you");
|
||||
|
||||
expect(mentions).toEqual([{ agentId: "agent-001", agentName: "Alpha" }]);
|
||||
});
|
||||
|
||||
it("parseMentions handles underscores in mentions", async () => {
|
||||
mockAgentStore.listAgents.mockResolvedValue([
|
||||
{
|
||||
id: "agent-003",
|
||||
name: "My Agent",
|
||||
role: "reviewer",
|
||||
state: "idle",
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
metadata: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const chatManager = createChatManager();
|
||||
const mentions = await (chatManager as any).parseMentions("ping @My_Agent please");
|
||||
|
||||
expect(mentions).toEqual([{ agentId: "agent-003", agentName: "My Agent" }]);
|
||||
});
|
||||
|
||||
it("parseMentions returns empty array when no mentions are present", async () => {
|
||||
const chatManager = createChatManager();
|
||||
const mentions = await (chatManager as any).parseMentions("hello there");
|
||||
|
||||
expect(mentions).toEqual([]);
|
||||
expect(mockAgentStore.listAgents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("parseMentions returns empty array when agentStore is unavailable", async () => {
|
||||
const chatManager = createChatManagerWithoutAgentStore();
|
||||
const mentions = await (chatManager as any).parseMentions("hello @Alpha");
|
||||
|
||||
expect(mentions).toEqual([]);
|
||||
});
|
||||
|
||||
it("buildMentionContext includes agent details", async () => {
|
||||
mockAgentStore.listAgents.mockResolvedValue([
|
||||
{
|
||||
id: "agent-001",
|
||||
name: "Alpha",
|
||||
role: "executor",
|
||||
state: "running",
|
||||
taskId: "FN-2000",
|
||||
soul: "A".repeat(260),
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
metadata: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const chatManager = createChatManager();
|
||||
const context = await (chatManager as any).buildMentionContext([
|
||||
{ agentId: "agent-001", agentName: "Alpha" },
|
||||
]);
|
||||
|
||||
expect(context).toContain("The user mentioned the following agents in their message:");
|
||||
expect(context).toContain("@Alpha");
|
||||
expect(context).toContain("role: executor");
|
||||
expect(context).toContain("currently working on: FN-2000");
|
||||
expect(context).toContain("…");
|
||||
});
|
||||
|
||||
it("buildMentionContext returns empty string when mentions are empty", async () => {
|
||||
const chatManager = createChatManager();
|
||||
const context = await (chatManager as any).buildMentionContext([]);
|
||||
|
||||
expect(context).toBe("");
|
||||
});
|
||||
|
||||
it("sendMessage appends mention context to system prompt when mentions are present", async () => {
|
||||
mockAgentStore.listAgents.mockResolvedValue([
|
||||
{
|
||||
id: "agent-001",
|
||||
name: "Avery",
|
||||
role: "executor",
|
||||
state: "running",
|
||||
taskId: "FN-1948",
|
||||
soul: "Mention-aware executor",
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
metadata: {},
|
||||
},
|
||||
]);
|
||||
|
||||
let createOptions: any;
|
||||
__setCreateKbAgent(async (options: any) => {
|
||||
createOptions = options;
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [{ role: "assistant", content: "Done" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = createChatManager();
|
||||
await chatManager.sendMessage("chat-001", "hello @Avery");
|
||||
|
||||
expect(createOptions.systemPrompt).toContain("The user mentioned the following agents in their message:");
|
||||
expect(createOptions.systemPrompt).toContain("@Avery");
|
||||
expect(createOptions.systemPrompt).toContain("currently working on: FN-1948");
|
||||
});
|
||||
|
||||
it("sendMessage stores mention metadata on the user message", async () => {
|
||||
mockAgentStore.listAgents.mockResolvedValue([
|
||||
{
|
||||
id: "agent-001",
|
||||
name: "Avery",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
metadata: {},
|
||||
},
|
||||
]);
|
||||
|
||||
__setCreateKbAgent(async () => {
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [{ role: "assistant", content: "Done" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = createChatManager();
|
||||
await chatManager.sendMessage("chat-001", "hello @Avery");
|
||||
|
||||
expect(mockChatStore.addMessage).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"chat-001",
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: "hello @Avery",
|
||||
metadata: {
|
||||
mentions: [{ agentId: "agent-001", agentName: "Avery" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("accumulates streamed text and uses it for message persistence", async () => {
|
||||
// Track the callbacks to simulate streaming
|
||||
let onThinkingCb: ((delta: string) => void) | undefined;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import type {
|
||||
Agent,
|
||||
AgentStore,
|
||||
ChatMention,
|
||||
ChatStore,
|
||||
ChatSession,
|
||||
ChatSessionCreateInput,
|
||||
@@ -273,6 +274,106 @@ export class ChatManager {
|
||||
private agentStore?: AgentStore,
|
||||
) {}
|
||||
|
||||
private async listAgentsForMentions(): Promise<Agent[]> {
|
||||
if (!this.agentStore) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
this.agentStoreReady ??= this.agentStore.init();
|
||||
await this.agentStoreReady;
|
||||
return await this.agentStore.listAgents();
|
||||
} catch (agentListError) {
|
||||
const message = agentListError instanceof Error ? agentListError.message : String(agentListError);
|
||||
console.warn(`[chat] Failed to list agents for mention parsing: ${message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** A parsed @ mention of an agent in a chat message */
|
||||
private async parseMentions(content: string, agents?: Agent[]): Promise<ChatMention[]> {
|
||||
if (!this.agentStore) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = Array.from(content.matchAll(/@([\w-]+)/g), (match) => match[1] ?? "");
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const availableAgents = agents ?? (await this.listAgentsForMentions());
|
||||
if (availableAgents.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const agentsByName = new Map<string, Agent>();
|
||||
for (const agent of availableAgents) {
|
||||
agentsByName.set(agent.name.toLowerCase(), agent);
|
||||
}
|
||||
|
||||
const mentions: ChatMention[] = [];
|
||||
const seenAgentIds = new Set<string>();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalizedName = candidate.replace(/_/g, " ").toLowerCase();
|
||||
const matchedAgent = agentsByName.get(normalizedName);
|
||||
if (!matchedAgent || seenAgentIds.has(matchedAgent.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mentions.push({
|
||||
agentId: matchedAgent.id,
|
||||
agentName: matchedAgent.name,
|
||||
});
|
||||
seenAgentIds.add(matchedAgent.id);
|
||||
}
|
||||
|
||||
return mentions;
|
||||
}
|
||||
|
||||
private async buildMentionContext(mentions: ChatMention[], agents?: Agent[]): Promise<string> {
|
||||
if (!this.agentStore || mentions.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const availableAgents = agents ?? (await this.listAgentsForMentions());
|
||||
if (availableAgents.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const agentsById = new Map<string, Agent>();
|
||||
for (const agent of availableAgents) {
|
||||
agentsById.set(agent.id, agent);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const mention of mentions) {
|
||||
const matchedAgent = agentsById.get(mention.agentId);
|
||||
if (!matchedAgent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const taskAssignment = matchedAgent.taskId?.trim() ? matchedAgent.taskId.trim() : "none";
|
||||
const soulOrInstructions = (matchedAgent.soul?.trim() || matchedAgent.instructionsText?.trim() || "")
|
||||
.replace(/\s+/g, " ");
|
||||
const description = soulOrInstructions.length > 200
|
||||
? `${soulOrInstructions.slice(0, 200)}…`
|
||||
: soulOrInstructions;
|
||||
|
||||
const base = `- @${mention.agentName.replace(/\s+/g, "_")} (role: ${matchedAgent.role}, currently working on: ${taskAssignment})`;
|
||||
lines.push(description ? `${base}: ${description}` : base);
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
"The user mentioned the following agents in their message:",
|
||||
...lines,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chat session.
|
||||
*/
|
||||
@@ -312,12 +413,17 @@ export class ChatManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasMentionCandidates = /@[\w-]+/.test(content);
|
||||
const mentionAgents = hasMentionCandidates ? await this.listAgentsForMentions() : [];
|
||||
const mentions = hasMentionCandidates ? await this.parseMentions(content, mentionAgents) : [];
|
||||
|
||||
// Persist user message
|
||||
let _userMessageId: string;
|
||||
try {
|
||||
const userMessage = this.chatStore.addMessage(sessionId, {
|
||||
role: "user",
|
||||
content,
|
||||
metadata: mentions.length > 0 ? { mentions } : undefined,
|
||||
});
|
||||
_userMessageId = userMessage.id;
|
||||
} catch (err) {
|
||||
@@ -399,6 +505,13 @@ export class ChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (mentions.length > 0) {
|
||||
const mentionContext = await this.buildMentionContext(mentions, mentionAgents);
|
||||
if (mentionContext) {
|
||||
systemPrompt = `${systemPrompt}\n\n${mentionContext}`;
|
||||
}
|
||||
}
|
||||
|
||||
const allMessages = this.chatStore.getMessages(sessionId, { limit: 10000 }) ?? [];
|
||||
const previousMessages = allMessages.slice(-51, -1);
|
||||
const conversationMessages = previousMessages.filter(
|
||||
|
||||
@@ -603,7 +603,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
// Create ChatStore for chat session management
|
||||
const chatStore = options?.chatStore ?? new ChatStore(store.getFusionDir(), store.getDatabase());
|
||||
|
||||
// Create AgentStore for chat prompt enrichment (lazy-initialized inside ChatManager)
|
||||
// Create AgentStore for chat prompt enrichment (initialized lazily by ChatManager)
|
||||
const chatAgentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||
|
||||
// Create ChatManager for AI chat message handling
|
||||
|
||||
Reference in New Issue
Block a user