feat(FN-1887): merge fusion/fn-1887

This commit is contained in:
Fusion
2026-04-16 10:53:06 -07:00
committed by gsxdsm
parent 3aeb63ae68
commit 74b4d20c45
11 changed files with 203 additions and 14 deletions

View File

@@ -394,6 +394,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
const app = createServer(store, {
engine: cwdEngine,
engineManager,
centralCore: sharedCentralCore ?? undefined,
onMerge: (taskId) => cwdEngine.onMerge(taskId),
authStorage: dashboardAuthStorage,
modelRegistry,

View File

@@ -577,6 +577,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
app = createServer(store, {
engine: cwdEngine,
engineManager,
centralCore: centralCoreForEngine,
authStorage: dashboardAuthStorage,
modelRegistry,
automationStore,
@@ -737,6 +738,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Dev mode: no engine, pass individual proxy objects to createServer
app = createServer(store, {
onMerge,
centralCore: centralCoreForMesh ?? undefined,
authStorage: dashboardAuthStorage,
modelRegistry,
automationStore,

View File

@@ -558,6 +558,7 @@ export async function runServe(
const app = createServer(store, {
engine: cwdEngine,
engineManager,
centralCore: sharedCentralCore ?? undefined,
onMerge: (taskId) => cwdEngine.onMerge(taskId),
authStorage: dashboardAuthStorage,
modelRegistry,

View File

@@ -2025,6 +2025,25 @@ describe("TaskStore", () => {
expect(globalStore.getSettingsPath()).toContain("settings.json");
});
it("ignores legacy project-level globalMaxConcurrent values", async () => {
const db = (store as any).db;
const row = db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined;
const existingSettings = row?.settings ? JSON.parse(row.settings) : {};
existingSettings.maxConcurrent = 9;
existingSettings.globalMaxConcurrent = 4;
db.prepare("UPDATE config SET settings = ? WHERE id = 1").run(JSON.stringify(existingSettings));
const settings = await store.getSettings();
const fastSettings = await store.getSettingsFast();
const { project } = await store.getSettingsByScope();
expect(settings.maxConcurrent).toBe(9);
expect(fastSettings.maxConcurrent).toBe(9);
expect((settings as any).globalMaxConcurrent).toBeUndefined();
expect((fastSettings as any).globalMaxConcurrent).toBeUndefined();
expect((project as any).globalMaxConcurrent).toBeUndefined();
});
it("updateSettings creates config row if missing and persists settings", async () => {
// Manually delete the config row to simulate corruption/edge case
const db = (store as any).db;

View File

@@ -28,7 +28,8 @@ const LEGACY_BACKUP_DIR = ".kb/backups";
/**
* Canonicalizes a settings object by resolving legacy defaults.
* Currently handles the .kb/backups → .fusion/backups migration.
* Currently handles the .kb/backups → .fusion/backups migration and
* strips legacy fields that are no longer valid.
*
* This function applies only the exact-match legacy alias transformation.
* Other custom .kb/* paths are preserved as-is.
@@ -43,6 +44,14 @@ function canonicalizeSettings(settings: Settings): Settings {
autoBackupDir: ".fusion/backups",
};
}
// Strip legacy globalMaxConcurrent from project settings - this field was
// deprecated in favor of the global-level maxConcurrent in concurrency settings.
const { globalMaxConcurrent, ...rest } = settings as Settings & { globalMaxConcurrent?: number };
if (globalMaxConcurrent !== undefined) {
return rest as Settings;
}
return settings;
}

View File

@@ -306,6 +306,137 @@ describe("QuickChatFAB", () => {
});
});
it("switching back to a previous agent restores its conversation", async () => {
const sessionForAgent1: ChatSession = {
id: "session-agent-001",
agentId: "agent-001",
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const sessionForAgent2: ChatSession = {
id: "session-agent-002",
agentId: "agent-002",
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const agent1Messages = [
{
id: "msg-001",
sessionId: "session-agent-001",
role: "user" as const,
content: "Hello from agent 1",
createdAt: new Date().toISOString(),
},
{
id: "msg-002",
sessionId: "session-agent-001",
role: "assistant" as const,
content: "Hello from agent 1 assistant",
createdAt: new Date().toISOString(),
},
];
const agent2Messages = [
{
id: "msg-003",
sessionId: "session-agent-002",
role: "user" as const,
content: "Hello from agent 2",
createdAt: new Date().toISOString(),
},
{
id: "msg-004",
sessionId: "session-agent-002",
role: "assistant" as const,
content: "Agent 2 response",
createdAt: new Date().toISOString(),
},
];
// Setup: agent-001 has an existing session, agent-002 does not
// Override beforeEach's createChatSession mock (which returns session-001)
// so that creating agent-002's session returns the correct ID
mockCreateChatSession.mockResolvedValueOnce({ session: sessionForAgent2 });
mockFetchChatSessions
// Initial load: agent-001's existing session found
.mockResolvedValueOnce({ sessions: [sessionForAgent1] })
// Switch to agent-002: no session found → will create new
.mockResolvedValueOnce({ sessions: [] })
// Switch back to agent-001: should find the existing session
.mockResolvedValueOnce({ sessions: [sessionForAgent1] });
// Per-call message mocks
mockFetchChatMessages
.mockResolvedValueOnce({ messages: agent1Messages })
.mockResolvedValueOnce({ messages: agent2Messages })
.mockResolvedValueOnce({ messages: agent1Messages });
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Step 1: Open chat with agent-001 (existing session found)
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123", "active");
});
// Verify agent-001's messages are shown
await waitFor(() => {
expect(screen.getByText("Hello from agent 1")).toBeDefined();
expect(screen.getByText("Hello from agent 1 assistant")).toBeDefined();
});
// Step 2: Switch to agent-002 → messages should clear then load
fireEvent.change(screen.getByTestId("quick-chat-agent-select"), {
target: { value: "agent-002" },
});
// New session created for agent-002
await waitFor(() => {
expect(mockCreateChatSession).toHaveBeenLastCalledWith({ agentId: "agent-002" }, "proj-123");
});
// Verify agent-002's messages are shown
await waitFor(() => {
expect(screen.getByText("Hello from agent 2")).toBeDefined();
expect(screen.getByText("Agent 2 response")).toBeDefined();
});
// Step 3: Switch back to agent-001 → should restore original session
fireEvent.change(screen.getByTestId("quick-chat-agent-select"), {
target: { value: "agent-001" },
});
// Should find existing session (not create new)
await waitFor(() => {
// Verify fetchChatSessions was called with correct projectId on each switch
expect(mockFetchChatSessions.mock.calls).toEqual([
["proj-123", "active"],
["proj-123", "active"],
["proj-123", "active"],
]);
// Verify no new session was created for agent-001 (already had one)
expect(mockCreateChatSession).not.toHaveBeenLastCalledWith(
{ agentId: "agent-001" },
"proj-123",
);
});
// Verify agent-001's original messages are restored
await waitFor(() => {
expect(screen.getByText("Hello from agent 1")).toBeDefined();
expect(screen.getByText("Hello from agent 1 assistant")).toBeDefined();
});
// Verify fetchChatMessages was called with correct session IDs for each agent
expect(mockFetchChatMessages.mock.calls).toEqual([
["session-agent-001", { limit: 50 }, "proj-123"],
["session-agent-002", { limit: 50 }, "proj-123"],
["session-agent-001", { limit: 50 }, "proj-123"],
]);
});
it("shows placeholder text when conversation is empty", async () => {
mockFetchChatMessages.mockResolvedValue({ messages: [] });

View File

@@ -96,6 +96,24 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({
})),
}));
// Mock useMemoryBackendStatus hook
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: vi.fn(() => ({
currentBackend: "file",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
loading: false,
error: null,
refresh: vi.fn(),
})),
}));
// Mock PluginManager to avoid SSE setup in tests
vi.mock("../PluginManager", () => ({
PluginManager: vi.fn(({ addToast }) => (

View File

@@ -149,6 +149,10 @@ export function useQuickChat(
return;
}
// Clear old messages immediately so stale conversation doesn't briefly flash
// while the new agent's session loads
setMessages([]);
// New agent — initialize session
currentAgentIdRef.current = agentId;
await initializeSession(agentId);

View File

@@ -15290,12 +15290,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.get("/global-concurrency", async (_req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
const shouldClose = !options?.centralCore;
if (shouldClose) await central.init();
const state = await central.getGlobalConcurrencyState();
await central.close();
if (shouldClose) await central.close();
res.json(state);
} catch (err: unknown) {
@@ -15319,12 +15319,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
const shouldClose = !options?.centralCore;
if (shouldClose) await central.init();
const state = await central.updateGlobalConcurrency({ globalMaxConcurrent });
await central.close();
if (shouldClose) await central.close();
res.json(state);
} catch (err: unknown) {

View File

@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { join, dirname } from "node:path";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { Task, TaskStore, MergeResult, AutomationStore, RoutineStore } from "@fusion/core";
import type { Task, TaskStore, MergeResult, AutomationStore, RoutineStore, CentralCore } from "@fusion/core";
import { ChatStore } from "@fusion/core";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
import { createApiRoutes } from "./routes.js";
@@ -110,6 +110,10 @@ export interface ServerOptions {
/** ProjectEngineManager for uniform multi-project engine lifecycle.
* When provided, the server can resolve per-project engines for route handlers. */
engineManager?: import("@fusion/engine").ProjectEngineManager;
/** Shared CentralCore instance used by the engine manager.
* Routes that mutate central runtime state should use this instance so
* in-process listeners (for example global concurrency changes) are notified. */
centralCore?: CentralCore;
/** Custom merge handler — when provided, used instead of store.mergeTask */
onMerge?: (taskId: string) => Promise<MergeResult>;
/** When true, run API/websocket server only (skip frontend static assets + SPA fallback) */

View File

@@ -508,7 +508,7 @@ describe("InProcessRuntime", () => {
executorOptions.onStart?.({ id: "FN-1661" } as Task, join(testDir, "worktree-FN-1661"));
await vi.waitFor(async () => {
const agents = await store.listAgents();
const agents = await store.listAgents({ includeEphemeral: true });
expect(agents).toHaveLength(1);
expect(agents[0]).toMatchObject({
name: "executor-FN-1661",
@@ -551,7 +551,7 @@ describe("InProcessRuntime", () => {
const store = getAgentStore(runtime);
await vi.waitFor(async () => {
const agents = await store.listAgents();
const agents = await store.listAgents({ includeEphemeral: true });
expect(agents.some((agent: Agent) => agent.name === "executor-FN-2001")).toBe(true);
});
@@ -578,7 +578,7 @@ describe("InProcessRuntime", () => {
executorOptions.onStart?.({ id: "FN-AUTO1" } as Task, join(testDir, "worktree-FN-AUTO1"));
await vi.waitFor(async () => {
const agents = await store.listAgents();
const agents = await store.listAgents({ includeEphemeral: true });
expect(agents.some((a: Agent) => a.name === "executor-FN-AUTO1")).toBe(true);
});
@@ -620,7 +620,7 @@ describe("InProcessRuntime", () => {
onStartOptions.onStart?.({ id: "FN-AUTO2" } as Task, join(testDir, "worktree-FN-AUTO2"));
await vi.waitFor(async () => {
const agents = await store.listAgents();
const agents = await store.listAgents({ includeEphemeral: true });
expect(agents.some((a: Agent) => a.name === "executor-FN-AUTO2")).toBe(true);
});