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:
Fusion
2026-04-17 09:08:26 -07:00
committed by gsxdsm
parent 3a9c56fda3
commit 28accdb085
19 changed files with 1805 additions and 60 deletions

View File

@@ -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;

View File

@@ -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(

View File

@@ -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