feat(FN-1549): simplify chat creation with agent-only selection and auto title

- Remove task selection from chat creation flow
- Add agent-only chat creation with automatic title generation
- Introduce useChat hook for unified chat state management
- Update ChatView with simplified creation UI
- Add CSS styles for chat creation interface
- Add comprehensive tests for chat manager and routes
- Update routes to support agent-only chat creation
This commit is contained in:
Fusion
2026-04-15 01:49:41 -07:00
committed by gsxdsm
parent f93e343648
commit b9264670e2
9 changed files with 412 additions and 119 deletions

View File

@@ -6,6 +6,17 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ChatManager, __setCreateKbAgent, __resetChatState } from "../chat.js";
// ── Mock Setup ──────────────────────────────────────────────────────────────
// Mock summarizeTitle using vi.hoisted so it's available at module hoisting time
const { mockSummarizeTitle } = vi.hoisted(() => ({
mockSummarizeTitle: vi.fn(),
}));
vi.mock("@fusion/core", () => ({
summarizeTitle: mockSummarizeTitle,
}));
// ── Mock Store ──────────────────────────────────────────────────────────────
const mockChatStore = {
@@ -13,6 +24,7 @@ const mockChatStore = {
createSession: vi.fn(),
addMessage: vi.fn(),
getMessages: vi.fn(),
updateSession: vi.fn(),
};
// ── Tests ───────────────────────────────────────────────────────────────────
@@ -251,4 +263,110 @@ describe("ChatManager.sendMessage", () => {
expect(calls[1][0]).toBe("chat-001");
expect(calls[1][1].role).toBe("assistant");
});
it("generates title when session has no title", async () => {
mockSummarizeTitle.mockResolvedValue("Short Title");
__setCreateKbAgent(async (options: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
if (options.onText) options.onText("Response");
}),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = new ChatManager(
mockChatStore as any,
"/tmp/test",
);
await chatManager.sendMessage("chat-001", "This is a long message that needs to be summarized");
// Wait for the async title generation
await new Promise((resolve) => setTimeout(resolve, 100));
// Assert - summarizeTitle was called with the message content and model params
expect(mockSummarizeTitle).toHaveBeenCalledWith(
"This is a long message that needs to be summarized",
"/tmp/test",
undefined,
undefined,
);
// Assert - session was updated with the generated title
expect(mockChatStore.updateSession).toHaveBeenCalledWith("chat-001", { title: "Short Title" });
});
it("uses truncated content when summarizeTitle returns null", async () => {
mockSummarizeTitle.mockResolvedValue(null);
__setCreateKbAgent(async (options: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
if (options.onText) options.onText("Response");
}),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = new ChatManager(
mockChatStore as any,
"/tmp/test",
);
const longMessage = "A".repeat(300);
await chatManager.sendMessage("chat-001", longMessage);
// Wait for the async title generation
await new Promise((resolve) => setTimeout(resolve, 100));
// Assert - summarizeTitle was called
expect(mockSummarizeTitle).toHaveBeenCalled();
// Assert - session was updated with truncated content (first 60 chars)
expect(mockChatStore.updateSession).toHaveBeenCalledWith("chat-001", { title: "A".repeat(60) });
});
it("does not generate title when session already has a title", async () => {
mockChatStore.getSession.mockReturnValue({
id: "chat-001",
agentId: "agent-001",
status: "active",
title: "Existing Title",
});
__setCreateKbAgent(async (options: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
if (options.onText) options.onText("Response");
}),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = new ChatManager(
mockChatStore as any,
"/tmp/test",
);
await chatManager.sendMessage("chat-001", "This is a long message");
// Wait for potential async operations
await new Promise((resolve) => setTimeout(resolve, 100));
// Assert - summarizeTitle was NOT called
expect(mockSummarizeTitle).not.toHaveBeenCalled();
// Assert - updateSession was NOT called
expect(mockChatStore.updateSession).not.toHaveBeenCalled();
});
});

View File

@@ -84,6 +84,10 @@ const mockAddMessage = vi.fn();
const mockGetMessages = vi.fn();
const mockGetMessage = vi.fn();
// Mock AgentStore
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
const mockAgentStoreGetAgent = vi.fn();
// Mock ChatStore class for vi.mock
vi.mock("@fusion/core", () => {
return {
@@ -98,6 +102,10 @@ vi.mock("@fusion/core", () => {
getMessages = mockGetMessages;
getMessage = mockGetMessage;
},
AgentStore: class MockAgentStore {
init = mockAgentStoreInit;
getAgent = mockAgentStoreGetAgent;
},
};
});
@@ -238,11 +246,27 @@ describe("Chat API Routes", () => {
mockGetMessages.mockReset();
mockGetMessage.mockReset();
mockSendMessage.mockReset();
mockAgentStoreInit.mockResolvedValue(undefined);
mockAgentStoreGetAgent.mockReset();
// Setup default mocks
mockListSessions.mockReturnValue([]);
mockGetMessages.mockReturnValue([]);
// Default agent mock - agent with model config
mockAgentStoreGetAgent.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: {},
runtimeConfig: {
model: "anthropic/claude-sonnet-4-5",
},
});
store = new MockStore();
// Reset and use the shared mock instance
mockChatStore = mockChatStoreInstance;
@@ -319,7 +343,7 @@ describe("Chat API Routes", () => {
});
describe("POST /api/chat/sessions", () => {
it("creates session with required fields", async () => {
it("creates session with required fields and resolves model from agent config", async () => {
mockCreateSession.mockReturnValue(sampleSession);
const response = await request(
@@ -332,15 +356,16 @@ describe("Chat API Routes", () => {
expect(response.status).toBe(201);
expect((response.body as any).session.id).toBe("chat-abc123");
// Model is resolved from agent's runtimeConfig.model
expect(mockCreateSession).toHaveBeenCalledWith({
agentId: "agent-001",
title: null,
modelProvider: null,
modelId: null,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
});
it("creates session with optional fields", async () => {
it("creates session with title and resolves model from agent config", async () => {
const sessionWithOptions = {
...sampleSession,
title: "Custom Title",
@@ -356,8 +381,6 @@ describe("Chat API Routes", () => {
JSON.stringify({
agentId: "agent-001",
title: "Custom Title",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
}),
{ "content-type": "application/json" },
);
@@ -392,36 +415,52 @@ describe("Chat API Routes", () => {
expect((response.body as any).error).toContain("agentId is required");
});
it("returns 400 when modelProvider without modelId", async () => {
it("returns 404 when agent not found", async () => {
mockAgentStoreGetAgent.mockResolvedValueOnce(undefined);
const response = await request(
app,
"POST",
"/api/chat/sessions",
JSON.stringify({
agentId: "agent-001",
modelProvider: "anthropic",
}),
JSON.stringify({ agentId: "nonexistent" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("both be provided or neither");
expect(response.status).toBe(404);
expect((response.body as any).error).toContain("not found");
});
it("returns 400 when modelId without modelProvider", async () => {
it("creates session with default model when agent has no model config", async () => {
mockAgentStoreGetAgent.mockResolvedValueOnce({
id: "agent-002",
name: "Beta",
role: "reviewer",
state: "idle",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
metadata: {},
runtimeConfig: {},
});
const sessionNoModel = { ...sampleSession, agentId: "agent-002" };
mockCreateSession.mockReturnValue(sessionNoModel);
const response = await request(
app,
"POST",
"/api/chat/sessions",
JSON.stringify({
agentId: "agent-001",
modelId: "claude-sonnet-4-5",
}),
JSON.stringify({ agentId: "agent-002" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("both be provided or neither");
expect(response.status).toBe(201);
// No model resolved from agent config
expect(mockCreateSession).toHaveBeenCalledWith({
agentId: "agent-002",
title: null,
modelProvider: null,
modelId: null,
});
});
});

View File

@@ -17,6 +17,7 @@ import type {
ChatSession,
ChatSessionCreateInput,
} from "@fusion/core";
import { summarizeTitle } from "@fusion/core";
import { EventEmitter } from "node:events";
import type { Response } from "express";
import { SessionEventBuffer, writeSSEEvent, safeWriteSSE, formatSSEEvent } from "./sse-buffer.js";
@@ -311,10 +312,36 @@ export class ChatManager {
return;
}
// Use model from session if not overridden
// Use model from session if not overridden (needed for both AI response and title generation)
const effectiveModelProvider = modelProvider ?? session.modelProvider ?? undefined;
const effectiveModelId = modelId ?? session.modelId ?? undefined;
// Auto-generate chat title on first message if session has no title
const needsTitle = session.title === null || session.title === undefined || session.title.trim() === "";
if (needsTitle) {
// Fire-and-forget title generation (non-blocking)
(async () => {
try {
const generated = await summarizeTitle(
content.trim(),
this.rootDir,
effectiveModelProvider,
effectiveModelId,
);
const title = generated ?? content.trim().slice(0, 60).trim();
if (title) {
this.chatStore.updateSession(sessionId, { title });
}
} catch (err) {
// Fallback on any error
const fallback = content.trim().slice(0, 60).trim();
if (fallback) {
this.chatStore.updateSession(sessionId, { title: fallback });
}
}
})();
}
let agentResult: AgentResult | undefined;
let accumulatedThinking = "";
let accumulatedText = "";

View File

@@ -7684,7 +7684,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
/**
* POST /api/chat/sessions
* Create a new chat session.
* Body: { agentId: string, title?: string, modelProvider?: string, modelId?: string }
* Body: { agentId: string, title?: string }
* The model is resolved from the agent's runtimeConfig.model setting.
*/
router.post("/chat/sessions", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try {
@@ -7693,29 +7694,38 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw internalError("Chat store not available");
}
const { agentId, title, modelProvider, modelId } = req.body as {
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const { agentId, title } = req.body as {
agentId?: string;
title?: string;
modelProvider?: string;
modelId?: string;
};
if (!agentId || typeof agentId !== "string" || !agentId.trim()) {
throw badRequest("agentId is required");
}
// Validate optional model pair consistency
const normalizedProvider = validateOptionalModelField(modelProvider, "modelProvider");
const normalizedModelId = validateOptionalModelField(modelId, "modelId");
if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) {
throw badRequest("modelProvider and modelId must both be provided or neither");
// Fetch the agent to resolve model configuration
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw notFound(`Agent ${agentId} not found`);
}
// Parse the agent's model config from runtimeConfig.model
// Format: "provider/modelId" (e.g., "anthropic/claude-sonnet-4-5")
const runtimeModel = typeof agent.runtimeConfig?.model === "string" ? agent.runtimeConfig.model : "";
const slashIdx = runtimeModel.indexOf("/");
const resolvedProvider = slashIdx > 0 ? runtimeModel.slice(0, slashIdx) : undefined;
const resolvedModelId = slashIdx > 0 ? runtimeModel.slice(slashIdx + 1) : undefined;
const session = chatStore.createSession({
agentId: agentId.trim(),
title: title?.trim() || null,
modelProvider: normalizedProvider ?? null,
modelId: normalizedModelId ?? null,
modelProvider: resolvedProvider ?? null,
modelId: resolvedModelId ?? null,
});
res.status(201).json({ session });