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:
@@ -11,9 +11,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useChat } from "../hooks/useChat";
|
||||
import { useViewportMode } from "./Header";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { fetchModels } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { fetchAgents } from "../api";
|
||||
import type { Agent } from "@fusion/core";
|
||||
|
||||
export interface ChatViewProps {
|
||||
projectId?: string;
|
||||
@@ -45,45 +44,34 @@ const KB_AGENT_ID = "__kb_agent__";
|
||||
|
||||
interface NewChatDialogProps {
|
||||
onClose: () => void;
|
||||
onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string }) => void;
|
||||
onCreate: (input: { agentId: string }) => void;
|
||||
}
|
||||
|
||||
function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) {
|
||||
// Model selection state (single combined value: "provider/modelId" or "" for default)
|
||||
const [modelValue, setModelValue] = useState("");
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(true);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [agentsLoading, setAgentsLoading] = useState(true);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
||||
|
||||
// Load models on mount
|
||||
// Load agents on mount
|
||||
useEffect(() => {
|
||||
setModelsLoading(true);
|
||||
fetchModels()
|
||||
setAgentsLoading(true);
|
||||
fetchAgents()
|
||||
.then((response) => {
|
||||
setModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
setFavoriteModels(response.favoriteModels);
|
||||
setAgents(response);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - dropdown will show empty list
|
||||
setModels([]);
|
||||
// Silently fail - show empty list
|
||||
setAgents([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setModelsLoading(false);
|
||||
setAgentsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Parse modelValue into provider and modelId
|
||||
const parsed = parseModelValue(modelValue);
|
||||
// Always use the kb agent - agentId is metadata only
|
||||
onCreate({
|
||||
agentId: KB_AGENT_ID,
|
||||
modelProvider: parsed.provider,
|
||||
modelId: parsed.modelId,
|
||||
});
|
||||
if (!selectedAgentId) return;
|
||||
onCreate({ agentId: selectedAgentId });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -92,37 +80,38 @@ function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) {
|
||||
<h3>New Chat</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="chat-new-dialog-model-label">
|
||||
Model
|
||||
<CustomModelDropdown
|
||||
models={models}
|
||||
value={modelValue}
|
||||
onChange={setModelValue}
|
||||
placeholder={modelsLoading ? "Loading models..." : "Select a model…"}
|
||||
disabled={modelsLoading}
|
||||
label="Chat model"
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={(provider) => {
|
||||
setFavoriteProviders((prev) =>
|
||||
prev.includes(provider)
|
||||
? prev.filter((p) => p !== provider)
|
||||
: [provider, ...prev]
|
||||
);
|
||||
}}
|
||||
onToggleModelFavorite={(modelId) => {
|
||||
setFavoriteModels((prev) =>
|
||||
prev.includes(modelId)
|
||||
? prev.filter((m) => m !== modelId)
|
||||
: [modelId, ...prev]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
Agent
|
||||
{agentsLoading ? (
|
||||
<div className="chat-new-dialog-loading">Loading agents...</div>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="chat-new-dialog-empty">No agents available</div>
|
||||
) : (
|
||||
<div className="chat-new-dialog-agent-list">
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
className={`chat-new-dialog-agent-item${selectedAgentId === agent.id ? " chat-new-dialog-agent-item--selected" : ""}`}
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
data-testid={`agent-option-${agent.id}`}
|
||||
>
|
||||
<Bot size={16} />
|
||||
<span className="chat-new-dialog-agent-name">{agent.name}</span>
|
||||
<span className="chat-new-dialog-agent-role">{agent.role}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<div className="chat-new-dialog-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-sm btn-primary">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-sm btn-primary"
|
||||
disabled={!selectedAgentId}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
@@ -132,19 +121,7 @@ function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a combined model value ("provider/modelId") into its components.
|
||||
* Returns undefined for both fields if the value is empty or malformed.
|
||||
*/
|
||||
function parseModelValue(value: string): { provider?: string; modelId?: string } {
|
||||
if (!value) return {};
|
||||
const slashIdx = value.indexOf("/");
|
||||
if (slashIdx === -1) return {};
|
||||
return {
|
||||
provider: value.slice(0, slashIdx),
|
||||
modelId: value.slice(slashIdx + 1),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
const {
|
||||
@@ -170,6 +147,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [sidebarVisible, setSidebarVisible] = useState(true);
|
||||
const [agentsMap, setAgentsMap] = useState<Map<string, Agent>>(new Map());
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -191,9 +169,24 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
}
|
||||
}, [contextMenu]);
|
||||
|
||||
// Fetch agents on mount for name resolution
|
||||
useEffect(() => {
|
||||
fetchAgents()
|
||||
.then((agents) => {
|
||||
const map = new Map<string, Agent>();
|
||||
for (const agent of agents) {
|
||||
map.set(agent.id, agent);
|
||||
}
|
||||
setAgentsMap(map);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - keep empty map
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle create session
|
||||
const handleCreateSession = useCallback(
|
||||
async (input: { agentId: string; modelProvider?: string; modelId?: string }) => {
|
||||
async (input: { agentId: string }) => {
|
||||
try {
|
||||
await createSession(input);
|
||||
setShowNewDialog(false);
|
||||
@@ -357,7 +350,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
{session.lastMessagePreview || "No messages"}
|
||||
</div>
|
||||
<div className="chat-session-meta">
|
||||
<span>{session.agentId === KB_AGENT_ID ? "AI Assistant" : session.agentId.slice(0, 30)}</span>
|
||||
<span>{agentsMap.get(session.agentId)?.name || (session.agentId === KB_AGENT_ID ? "AI Assistant" : session.agentId.slice(0, 30))}</span>
|
||||
<span>{session.updatedAt ? formatRelativeTime(session.updatedAt) : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,7 +420,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
)}
|
||||
<Bot size={16} />
|
||||
<span className="chat-thread-header-title">
|
||||
{activeSession?.title || activeSession?.agentId || "Chat"}
|
||||
{activeSession?.title || agentsMap.get(activeSession?.agentId ?? "")?.name || activeSession?.agentId || "Chat"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock("lucide-react", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock CustomModelDropdown as a simple test double
|
||||
// Mock CustomModelDropdown - no longer used but kept for other tests
|
||||
vi.mock("../CustomModelDropdown", () => ({
|
||||
CustomModelDropdown: ({
|
||||
value,
|
||||
@@ -59,7 +59,7 @@ vi.mock("../CustomModelDropdown", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock fetchModels
|
||||
// Mock fetchAgents for new chat dialog
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn().mockResolvedValue({
|
||||
models: [
|
||||
@@ -69,6 +69,10 @@ vi.mock("../../api", () => ({
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
}),
|
||||
fetchAgents: vi.fn().mockResolvedValue([
|
||||
{ id: "agent-001", name: "Alpha", role: "executor", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
|
||||
{ id: "agent-002", name: "Beta", role: "reviewer", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
|
||||
]),
|
||||
}));
|
||||
|
||||
const defaultChatState = {
|
||||
@@ -173,12 +177,12 @@ describe("ChatView", () => {
|
||||
// Dialog should be open - check for dialog content
|
||||
const dialog = document.querySelector(".chat-new-dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
// Should show Model label (not Agent)
|
||||
expect(within(dialog!).getByText("Model")).toBeInTheDocument();
|
||||
// Should show Agent label
|
||||
expect(within(dialog!).getByText("Agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates session without model selection (uses default)", async () => {
|
||||
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "__kb_agent__" });
|
||||
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-001" });
|
||||
setupMockChat({ sessions: [], filteredSessions: [], createSession });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
@@ -187,23 +191,27 @@ describe("ChatView", () => {
|
||||
|
||||
const dialog = document.querySelector(".chat-new-dialog");
|
||||
|
||||
// Select "Use default" (the first option)
|
||||
const select = within(dialog!).getByTestId("mock-model-dropdown") as HTMLSelectElement;
|
||||
expect(select.value).toBe("");
|
||||
// Create button should be disabled initially (no agent selected)
|
||||
const createBtn = within(dialog!).getByText("Create") as HTMLButtonElement;
|
||||
expect(createBtn).toBeDisabled();
|
||||
|
||||
// Click on an agent to select it
|
||||
await userEvent.click(within(dialog!).getByTestId("agent-option-agent-001"));
|
||||
|
||||
// Create button should now be enabled
|
||||
expect(createBtn).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(within(dialog!).getByText("Create"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSession).toHaveBeenCalledWith({
|
||||
agentId: "__kb_agent__",
|
||||
modelProvider: undefined,
|
||||
modelId: undefined,
|
||||
agentId: "agent-001",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("creates session with model selection", async () => {
|
||||
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "__kb_agent__" });
|
||||
it("creates session with agent selection", async () => {
|
||||
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-002" });
|
||||
setupMockChat({ sessions: [], filteredSessions: [], createSession });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
@@ -211,16 +219,15 @@ describe("ChatView", () => {
|
||||
await userEvent.click(screen.getByTestId("chat-new-btn"));
|
||||
|
||||
const dialog = document.querySelector(".chat-new-dialog");
|
||||
const select = within(dialog!).getByTestId("mock-model-dropdown") as HTMLSelectElement;
|
||||
await userEvent.selectOptions(select, "anthropic/claude-sonnet-4-5");
|
||||
|
||||
// Click on a different agent
|
||||
await userEvent.click(within(dialog!).getByTestId("agent-option-agent-002"));
|
||||
|
||||
await userEvent.click(within(dialog!).getByText("Create"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSession).toHaveBeenCalledWith({
|
||||
agentId: "__kb_agent__",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
agentId: "agent-002",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,10 @@ vi.mock("../../api", () => ({
|
||||
updateChatSession: vi.fn(),
|
||||
deleteChatSession: vi.fn(),
|
||||
streamChatResponse: vi.fn(),
|
||||
fetchAgents: vi.fn().mockResolvedValue([
|
||||
{ id: "agent-001", name: "Alpha", role: "executor", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
|
||||
{ id: "agent-002", name: "Beta", role: "reviewer", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
|
||||
]),
|
||||
}));
|
||||
|
||||
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
|
||||
@@ -25,6 +29,7 @@ const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
|
||||
const mockUpdateChatSession = vi.mocked(apiModule.updateChatSession);
|
||||
const mockDeleteChatSession = vi.mocked(apiModule.deleteChatSession);
|
||||
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
|
||||
const mockFetchAgents = vi.mocked(apiModule.fetchAgents);
|
||||
|
||||
function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" | "agentId">): ChatSession {
|
||||
return {
|
||||
@@ -91,6 +96,21 @@ describe("useChat", () => {
|
||||
expect(result.current.sessions[1]?.id).toBe("session-002");
|
||||
});
|
||||
|
||||
it("populates agentsMap on mount", async () => {
|
||||
const { result } = renderHook(() => useChat("proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.agentsMap.size).toBe(2);
|
||||
});
|
||||
|
||||
expect(result.current.agentsMap.get("agent-001")?.name).toBe("Alpha");
|
||||
expect(result.current.agentsMap.get("agent-002")?.name).toBe("Beta");
|
||||
});
|
||||
|
||||
it("selects a session and loads its messages", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
updateChatSession,
|
||||
deleteChatSession,
|
||||
streamChatResponse,
|
||||
fetchAgents,
|
||||
type ChatSessionListResponse,
|
||||
} from "../api";
|
||||
import type { Agent } from "@fusion/core";
|
||||
|
||||
export interface ChatSessionInfo {
|
||||
id: string;
|
||||
@@ -64,6 +66,9 @@ export interface UseChatReturn {
|
||||
|
||||
// Refresh
|
||||
refreshSessions: () => Promise<void>;
|
||||
|
||||
// Agent name resolution
|
||||
agentsMap: Map<string, Agent>;
|
||||
}
|
||||
|
||||
export function useChat(projectId?: string): UseChatReturn {
|
||||
@@ -85,9 +90,27 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
// Pagination
|
||||
const [hasMoreMessages, setHasMoreMessages] = useState(true);
|
||||
|
||||
// Agent name resolution map
|
||||
const [agentsMap, setAgentsMap] = useState<Map<string, Agent>>(new Map());
|
||||
|
||||
// Stream connection ref for cleanup
|
||||
const streamRef = useRef<{ close: () => void } | null>(null);
|
||||
|
||||
// Fetch agents on mount for name resolution
|
||||
useEffect(() => {
|
||||
fetchAgents()
|
||||
.then((agents) => {
|
||||
const map = new Map<string, Agent>();
|
||||
for (const agent of agents) {
|
||||
map.set(agent.id, agent);
|
||||
}
|
||||
setAgentsMap(map);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - keep empty map
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Fetch sessions
|
||||
const refreshSessions = useCallback(async () => {
|
||||
setSessionsLoading(true);
|
||||
@@ -347,5 +370,6 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
setSearchQuery,
|
||||
filteredSessions,
|
||||
refreshSessions,
|
||||
agentsMap,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27722,6 +27722,61 @@ html .column.drag-over * {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Agent list in new chat dialog */
|
||||
.chat-new-dialog-loading,
|
||||
.chat-new-dialog-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-new-dialog-agent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.chat-new-dialog-agent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.chat-new-dialog-agent-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chat-new-dialog-agent-item--selected {
|
||||
background: var(--accent-color-light, rgba(59, 130, 246, 0.1));
|
||||
border: 1px solid var(--accent-color, rgba(59, 130, 246, 0.3));
|
||||
}
|
||||
|
||||
.chat-new-dialog-agent-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat-new-dialog-agent-role {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* Ensure dropdown portal renders above dialog */
|
||||
.chat-new-dialog {
|
||||
overflow: visible;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user