feat(FN-1887): merge fusion/fn-1887
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user