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

This commit is contained in:
gsxdsm
2026-04-16 10:38:07 -07:00
parent a118333c7b
commit 86004118bc
3 changed files with 142 additions and 19 deletions

View File

@@ -11,8 +11,10 @@ import {
} from "lucide-react";
import { useChat } from "../hooks/useChat";
import { useViewportMode } from "./Header";
import { fetchAgents } from "../api";
import { fetchAgents, fetchModels } from "../api";
import type { Agent } from "@fusion/core";
import type { ModelInfo } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
export interface ChatViewProps {
projectId?: string;
@@ -110,13 +112,16 @@ const KB_AGENT_ID = "__kb_agent__";
interface NewChatDialogProps {
onClose: () => void;
onCreate: (input: { agentId: string }) => void;
onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string }) => void;
}
function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) {
const [agents, setAgents] = useState<Agent[]>([]);
const [agentsLoading, setAgentsLoading] = useState(true);
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
const [models, setModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(true);
const [selectedModel, setSelectedModel] = useState<string>("");
// Load agents on mount
useEffect(() => {
@@ -134,10 +139,41 @@ function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) {
});
}, []);
// Load models on mount
useEffect(() => {
setModelsLoading(true);
fetchModels()
.then((response) => {
setModels(response.models);
})
.catch(() => {
// Silently fail - show empty list
setModels([]);
})
.finally(() => {
setModelsLoading(false);
});
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedAgentId) return;
onCreate({ agentId: selectedAgentId });
// Parse model selection into provider and modelId
let modelProvider: string | undefined;
let modelId: string | undefined;
if (selectedModel) {
const slashIdx = selectedModel.indexOf("/");
if (slashIdx > 0) {
modelProvider = selectedModel.slice(0, slashIdx);
modelId = selectedModel.slice(slashIdx + 1);
}
}
onCreate({
agentId: selectedAgentId,
...(modelProvider && modelId ? { modelProvider, modelId } : {}),
});
};
return (
@@ -169,6 +205,19 @@ function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) {
</div>
)}
</label>
<div className="chat-new-dialog-model-dropdown">
{modelsLoading ? (
<div className="chat-new-dialog-loading">Loading models...</div>
) : (
<CustomModelDropdown
models={models}
value={selectedModel}
onChange={setSelectedModel}
label="Model"
placeholder="Use agent default"
/>
)}
</div>
<div className="chat-new-dialog-actions">
<button type="button" className="btn btn-sm" onClick={onClose}>
Cancel
@@ -252,7 +301,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
// Handle create session
const handleCreateSession = useCallback(
async (input: { agentId: string }) => {
async (input: { agentId: string; modelProvider?: string; modelId?: string }) => {
try {
await createSession(input);
setShowNewDialog(false);

View File

@@ -236,6 +236,60 @@ describe("ChatView", () => {
});
});
it("creates session with model selection", async () => {
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-001" });
setupMockChat({ sessions: [], filteredSessions: [], createSession });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-new-btn"));
const dialog = document.querySelector(".chat-new-dialog");
// Click on an agent to select it
await userEvent.click(within(dialog!).getByTestId("agent-option-agent-001"));
// Select a model from the dropdown
const modelDropdown = within(dialog!).getByTestId("mock-model-dropdown");
await userEvent.selectOptions(modelDropdown, "anthropic/claude-sonnet-4-5");
await userEvent.click(within(dialog!).getByText("Create"));
await waitFor(() => {
expect(createSession).toHaveBeenCalledWith({
agentId: "agent-001",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
});
});
it("creates session without model selection omits model fields", async () => {
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-001" });
setupMockChat({ sessions: [], filteredSessions: [], createSession });
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-new-btn"));
const dialog = document.querySelector(".chat-new-dialog");
// Click on an agent to select it
await userEvent.click(within(dialog!).getByTestId("agent-option-agent-001"));
// Make sure no model is selected (use default)
const modelDropdown = within(dialog!).getByTestId("mock-model-dropdown");
await userEvent.selectOptions(modelDropdown, "");
await userEvent.click(within(dialog!).getByText("Create"));
await waitFor(() => {
expect(createSession).toHaveBeenCalledWith({
agentId: "agent-001",
});
});
});
it("renders messages for active session", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },

View File

@@ -8381,8 +8381,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
/**
* POST /api/chat/sessions
* Create a new chat session.
* Body: { agentId: string, title?: string }
* The model is resolved from the agent's runtimeConfig.model setting.
* Body: { agentId: string, title?: string, modelProvider?: string, modelId?: string }
* If modelProvider and modelId are provided, those are used. Otherwise the model is
* resolved from the agent's runtimeConfig.model setting.
* The session is scoped to the project identified by projectId query param or header.
*/
router.post("/chat/sessions", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
@@ -8398,35 +8399,54 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const { agentId, title } = req.body as {
const { agentId, title, modelProvider, modelId } = req.body as {
agentId?: string;
title?: string;
modelProvider?: string;
modelId?: string;
};
if (!agentId || typeof agentId !== "string" || !agentId.trim()) {
throw badRequest("agentId is required");
}
// Fetch the agent to resolve model configuration
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw notFound(`Agent ${agentId} not found`);
// Validate that if one model field is provided, the other must also be provided
const hasClientModelProvider = typeof modelProvider === "string" && modelProvider.trim() !== "";
const hasClientModelId = typeof modelId === "string" && modelId.trim() !== "";
if (hasClientModelProvider !== hasClientModelId) {
throw badRequest("Both modelProvider and modelId must be provided together, or neither should be provided");
}
// 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;
// Fetch the agent to resolve model configuration (only if client didn't provide model)
let resolvedProvider: string | null = null;
let resolvedModelId: string | null = null;
if (hasClientModelProvider && hasClientModelId) {
// Use client-provided model
resolvedProvider = modelProvider!.trim();
resolvedModelId = modelId!.trim();
} else {
// Resolve from agent's runtimeConfig.model
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("/");
resolvedProvider = slashIdx > 0 ? runtimeModel.slice(0, slashIdx) : null;
resolvedModelId = slashIdx > 0 ? runtimeModel.slice(slashIdx + 1) : null;
}
// Create the chat session with projectId for multi-project scoping
const session = chatStore.createSession({
agentId: agentId.trim(),
title: title?.trim() || null,
projectId: projectId ?? null,
modelProvider: resolvedProvider ?? null,
modelId: resolvedModelId ?? null,
modelProvider: resolvedProvider,
modelId: resolvedModelId,
});
res.status(201).json({ session });