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

This commit is contained in:
gsxdsm
2026-04-24 08:38:24 -07:00
parent f69fb4fe76
commit 4f633be65e
10 changed files with 471 additions and 64 deletions

View File

@@ -181,6 +181,110 @@ describe("ChatStore", () => {
});
});
describe("findLatestActiveSessionForTarget", () => {
it("returns newest exact model match for model-specific targets", async () => {
const olderModelMatch = createTestSession(store, {
agentId: "agent-lookup",
projectId: "proj-1",
modelProvider: "openai",
modelId: "gpt-4o",
});
await new Promise((r) => setTimeout(r, 5));
const newestModelMatch = createTestSession(store, {
agentId: "agent-lookup",
projectId: "proj-1",
modelProvider: "openai",
modelId: "gpt-4o",
});
createTestSession(store, {
agentId: "agent-lookup",
projectId: "proj-1",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
const found = store.findLatestActiveSessionForTarget({
projectId: "proj-1",
agentId: "agent-lookup",
modelProvider: "openai",
modelId: "gpt-4o",
});
expect(found?.id).toBe(newestModelMatch.id);
expect(found?.id).not.toBe(olderModelMatch.id);
});
it("prefers model-less session for agent-only targets", async () => {
const modelSpecific = createTestSession(store, {
agentId: "agent-lookup",
projectId: "proj-1",
modelProvider: "openai",
modelId: "gpt-4o",
});
await new Promise((r) => setTimeout(r, 5));
const modelLess = createTestSession(store, {
agentId: "agent-lookup",
projectId: "proj-1",
});
const found = store.findLatestActiveSessionForTarget({
projectId: "proj-1",
agentId: "agent-lookup",
});
expect(found?.id).toBe(modelLess.id);
expect(found?.id).not.toBe(modelSpecific.id);
});
it("falls back to newest agent session when no model-less session exists", async () => {
createTestSession(store, {
agentId: "agent-lookup",
projectId: "proj-1",
modelProvider: "openai",
modelId: "gpt-4o-mini",
});
await new Promise((r) => setTimeout(r, 5));
const newestModelSpecific = createTestSession(store, {
agentId: "agent-lookup",
projectId: "proj-1",
modelProvider: "openai",
modelId: "gpt-4o",
});
const found = store.findLatestActiveSessionForTarget({
projectId: "proj-1",
agentId: "agent-lookup",
});
expect(found?.id).toBe(newestModelSpecific.id);
});
it("returns undefined when there is no matching active session", () => {
createTestSession(store, {
agentId: "agent-lookup",
projectId: "proj-1",
});
const found = store.findLatestActiveSessionForTarget({
projectId: "proj-2",
agentId: "agent-lookup",
});
expect(found).toBeUndefined();
});
it("throws for inconsistent model-provider query pairs", () => {
expect(() =>
store.findLatestActiveSessionForTarget({
projectId: "proj-1",
agentId: "agent-lookup",
modelProvider: "openai",
}),
).toThrow("modelProvider and modelId must both be provided together, or neither");
});
});
describe("updateSession", () => {
it("updates title and bumps updatedAt", async () => {
const session = createTestSession(store);

View File

@@ -203,6 +203,74 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
return (rows as unknown as ChatSessionRow[]).map((row) => this.rowToSession(row));
}
/**
* Find the newest active session for a specific quick-chat target.
*
* Matching semantics:
* - model target (`modelProvider` + `modelId`): exact agent+model match
* - agent target (no model): prefer model-less sessions, then newest agent session fallback
*/
findLatestActiveSessionForTarget(options: {
agentId: string;
projectId?: string;
modelProvider?: string;
modelId?: string;
}): ChatSession | undefined {
const normalizedAgentId = options.agentId.trim();
if (!normalizedAgentId) {
return undefined;
}
const normalizedProvider = options.modelProvider?.trim();
const normalizedModelId = options.modelId?.trim();
if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) {
throw new Error("modelProvider and modelId must both be provided together, or neither");
}
const whereClauses: string[] = ["status = ?", "agentId = ?"];
const baseParams: string[] = ["active", normalizedAgentId];
if (options.projectId && options.projectId.trim()) {
whereClauses.push("projectId = ?");
baseParams.push(options.projectId.trim());
}
const baseWhereSql = whereClauses.join(" AND ");
if (normalizedProvider && normalizedModelId) {
const row = this.db.prepare(`
SELECT * FROM chat_sessions
WHERE ${baseWhereSql} AND modelProvider = ? AND modelId = ?
ORDER BY updatedAt DESC
LIMIT 1
`).get(...baseParams, normalizedProvider, normalizedModelId) as ChatSessionRow | undefined;
return row ? this.rowToSession(row) : undefined;
}
const modelLessRow = this.db.prepare(`
SELECT * FROM chat_sessions
WHERE ${baseWhereSql}
AND COALESCE(TRIM(modelProvider), '') = ''
AND COALESCE(TRIM(modelId), '') = ''
ORDER BY updatedAt DESC
LIMIT 1
`).get(...baseParams) as ChatSessionRow | undefined;
if (modelLessRow) {
return this.rowToSession(modelLessRow);
}
const fallbackRow = this.db.prepare(`
SELECT * FROM chat_sessions
WHERE ${baseWhereSql}
ORDER BY updatedAt DESC
LIMIT 1
`).get(...baseParams) as ChatSessionRow | undefined;
return fallbackRow ? this.rowToSession(fallbackRow) : undefined;
}
/**
* Update a chat session.
*

View File

@@ -6557,6 +6557,45 @@ export function fetchChatSessions(projectId?: string, status?: string): Promise<
return api<ChatSessionListResponse>(`/chat/sessions${qs ? `?${qs}` : ""}`);
}
export interface ChatSessionResumeLookupInput {
agentId: string;
modelProvider?: string;
modelId?: string;
}
/**
* Fetch the most relevant active session for quick-chat resume semantics.
* Returns at most one session for the provided target.
*/
export async function fetchResumeChatSession(
input: ChatSessionResumeLookupInput,
projectId?: string,
): Promise<{ session: EnrichedChatSession | null }> {
const normalizedAgentId = input.agentId.trim();
if (!normalizedAgentId) {
throw new Error("agentId is required");
}
const normalizedProvider = input.modelProvider?.trim();
const normalizedModelId = input.modelId?.trim();
if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) {
throw new Error("Both modelProvider and modelId must be provided together, or neither should be provided");
}
const search = new URLSearchParams();
search.set("lookup", "resume");
search.set("agentId", normalizedAgentId);
if (projectId) search.set("projectId", projectId);
if (normalizedProvider && normalizedModelId) {
search.set("modelProvider", normalizedProvider);
search.set("modelId", normalizedModelId);
}
const data = await api<ChatSessionListResponse>(`/chat/sessions?${search.toString()}`);
return { session: data.sessions[0] ?? null };
}
/** Create a new chat session */
export function createChatSession(
input: { agentId: string; title?: string; modelProvider?: string; modelId?: string },

View File

@@ -6,6 +6,7 @@ import { useAgents } from "../../hooks/useAgents";
import { QuickChatFAB } from "../QuickChatFAB";
vi.mock("../../api", () => ({
fetchResumeChatSession: vi.fn(),
fetchChatSessions: vi.fn(),
createChatSession: vi.fn(),
fetchChatMessages: vi.fn(),
@@ -19,6 +20,7 @@ vi.mock("../../hooks/useAgents", () => ({
useAgents: vi.fn(),
}));
const mockFetchResumeChatSession = vi.mocked(apiModule.fetchResumeChatSession);
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
@@ -148,6 +150,7 @@ describe("QuickChatFAB", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAgentsHook(mockAgents);
mockFetchResumeChatSession.mockResolvedValue({ session: null });
mockFetchChatSessions.mockResolvedValue({ sessions: [] });
mockCreateChatSession.mockResolvedValue({ session: mockSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
@@ -385,7 +388,7 @@ describe("QuickChatFAB", () => {
};
mockAgentsHook([]);
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingModelSession] });
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingModelSession });
mockCreateChatSession.mockResolvedValueOnce({ session: freshModelSession });
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
@@ -504,7 +507,7 @@ describe("QuickChatFAB", () => {
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
@@ -549,7 +552,7 @@ describe("QuickChatFAB", () => {
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
@@ -577,7 +580,7 @@ describe("QuickChatFAB", () => {
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
@@ -610,7 +613,7 @@ describe("QuickChatFAB", () => {
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
@@ -636,7 +639,7 @@ describe("QuickChatFAB", () => {
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
@@ -660,7 +663,7 @@ describe("QuickChatFAB", () => {
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
@@ -697,7 +700,7 @@ describe("QuickChatFAB", () => {
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
@@ -728,7 +731,7 @@ describe("QuickChatFAB", () => {
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
@@ -750,7 +753,7 @@ describe("QuickChatFAB", () => {
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
@@ -777,7 +780,7 @@ describe("QuickChatFAB", () => {
it("switching agents creates a new session for the selected agent", async () => {
// First session exists
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [mockSession] });
mockFetchResumeChatSession.mockResolvedValueOnce({ session: mockSession });
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
@@ -785,7 +788,7 @@ describe("QuickChatFAB", () => {
// Wait for initial session to be created
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
// Switch to agent-002
@@ -852,13 +855,13 @@ describe("QuickChatFAB", () => {
// 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
mockFetchResumeChatSession
// Initial load: agent-001's existing session found
.mockResolvedValueOnce({ sessions: [sessionForAgent1] })
.mockResolvedValueOnce({ session: sessionForAgent1 })
// Switch to agent-002: no session found → will create new
.mockResolvedValueOnce({ sessions: [] })
.mockResolvedValueOnce({ session: null })
// Switch back to agent-001: should find the existing session
.mockResolvedValueOnce({ sessions: [sessionForAgent1] });
.mockResolvedValueOnce({ session: sessionForAgent1 });
// Per-call message mocks
mockFetchChatMessages
@@ -871,7 +874,14 @@ describe("QuickChatFAB", () => {
// Step 1: Open chat with agent-001 (existing session found)
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123", "active");
expect(mockFetchResumeChatSession).toHaveBeenCalledWith(
{
agentId: "agent-001",
modelProvider: undefined,
modelId: undefined,
},
"proj-123",
);
});
// Verify agent-001's messages are shown
@@ -903,11 +913,11 @@ describe("QuickChatFAB", () => {
// 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 targeted resume lookup was called with the selected agent on each switch
expect(mockFetchResumeChatSession.mock.calls).toEqual([
[{ agentId: "agent-001", modelProvider: undefined, modelId: undefined }, "proj-123"],
[{ agentId: "agent-002", modelProvider: undefined, modelId: undefined }, "proj-123"],
[{ agentId: "agent-001", modelProvider: undefined, modelId: undefined }, "proj-123"],
]);
// Verify no new session was created for agent-001 (already had one)
expect(mockCreateChatSession).not.toHaveBeenLastCalledWith(
@@ -1204,7 +1214,7 @@ describe("QuickChatFAB", () => {
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
expect(mockFetchResumeChatSession).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");

View File

@@ -5,6 +5,7 @@ import * as apiModule from "../../api";
import { FN_AGENT_ID, useQuickChat } from "../useQuickChat";
vi.mock("../../api", () => ({
fetchResumeChatSession: vi.fn(),
fetchChatSessions: vi.fn(),
createChatSession: vi.fn(),
fetchChatMessages: vi.fn(),
@@ -12,6 +13,7 @@ vi.mock("../../api", () => ({
cancelChatResponse: vi.fn(),
}));
const mockFetchResumeChatSession = vi.mocked(apiModule.fetchResumeChatSession);
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
@@ -35,6 +37,7 @@ function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" |
describe("useQuickChat", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchResumeChatSession.mockResolvedValue({ session: null });
mockFetchChatSessions.mockResolvedValue({ sessions: [] });
mockCreateChatSession.mockResolvedValue({
session: makeSession({ id: "session-001", agentId: "agent-001" }),
@@ -46,7 +49,7 @@ describe("useQuickChat", () => {
it("sendMessage is synchronous and returns void", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValue({ sessions: [session] });
mockFetchResumeChatSession.mockResolvedValue({ session });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const { result } = renderHook(() => useQuickChat("proj-123"));
@@ -140,9 +143,9 @@ describe("useQuickChat", () => {
}),
});
mockFetchChatSessions
.mockResolvedValueOnce({ sessions: [] })
.mockResolvedValueOnce({ sessions: [modelASession] });
mockFetchResumeChatSession
.mockResolvedValueOnce({ session: null })
.mockResolvedValueOnce({ session: null });
const { result } = renderHook(() => useQuickChat("proj-123"));
@@ -185,7 +188,7 @@ describe("useQuickChat", () => {
modelId: "gpt-4o",
});
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
const { result } = renderHook(() => useQuickChat("proj-123"));
@@ -203,6 +206,37 @@ describe("useQuickChat", () => {
});
});
it("resumes via targeted lookup without loading the full active-session list", async () => {
const existingSession = makeSession({
id: "session-targeted",
agentId: "agent-001",
modelProvider: "openai",
modelId: "gpt-4o",
});
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatSessions.mockRejectedValue(new Error("should not enumerate active sessions"));
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001", "openai", "gpt-4o");
});
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-targeted");
expect(mockFetchResumeChatSession).toHaveBeenCalledWith(
{
agentId: "agent-001",
modelProvider: "openai",
modelId: "gpt-4o",
},
"proj-123",
);
expect(mockFetchChatSessions).not.toHaveBeenCalled();
});
});
it("startFreshSession creates a second session for the same model target", async () => {
const existingSession = makeSession({
id: "session-existing",
@@ -217,7 +251,7 @@ describe("useQuickChat", () => {
modelId: "gpt-4o",
});
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockCreateChatSession.mockResolvedValueOnce({ session: freshSession });
const { result } = renderHook(() => useQuickChat("proj-123"));
@@ -252,7 +286,7 @@ describe("useQuickChat", () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const closeFn = vi.fn();
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockReturnValue({ close: closeFn, isConnected: () => true });
@@ -286,7 +320,7 @@ describe("useQuickChat", () => {
it("sending during streaming queues message", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
@@ -316,7 +350,7 @@ describe("useQuickChat", () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const handlers: Array<Parameters<typeof mockStreamChatResponse>[2]> = [];
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, nextHandlers) => {
handlers.push(nextHandlers);
@@ -356,7 +390,7 @@ describe("useQuickChat", () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
let onErrorHandler: ((data: string) => void) | undefined;
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages
.mockResolvedValueOnce({ messages: [] })
.mockResolvedValueOnce({
@@ -401,7 +435,7 @@ describe("useQuickChat", () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
let onErrorHandler: ((data: string) => void) | undefined;
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
@@ -435,7 +469,7 @@ describe("useQuickChat", () => {
let onTextHandler: ((data: string) => void) | undefined;
let onThinkingHandler: ((data: string) => void) | undefined;
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
@@ -479,7 +513,7 @@ describe("useQuickChat", () => {
const addToast = vi.fn();
let onErrorHandler: ((data: string) => void) | undefined;
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ChatMessage, ChatSession } from "@fusion/core";
import {
fetchChatSessions,
fetchResumeChatSession,
createChatSession,
fetchChatMessages,
streamChatResponse,
@@ -97,24 +97,6 @@ function buildSessionKey(agentId: string, modelProvider?: string, modelId?: stri
return `${agentId}::${provider}/${id}`;
}
function findMatchingSession(sessions: ChatSession[], target: SessionTarget): ChatSession | undefined {
const candidateSessions = sessions.filter((session) => session.agentId === target.agentId);
if (candidateSessions.length === 0) {
return undefined;
}
if (target.modelProvider && target.modelId) {
return candidateSessions.find(
(session) => session.modelProvider === target.modelProvider && session.modelId === target.modelId,
);
}
// Prefer sessions without explicit model data when available,
// then fall back to the first session for this agent to preserve
// existing behavior.
return candidateSessions.find((session) => !session.modelProvider && !session.modelId) ?? candidateSessions[0];
}
function extractCompletedToolCalls(metadata: Record<string, unknown> | null | undefined): ToolCallInfo[] | undefined {
const rawToolCalls = metadata?.toolCalls;
if (!Array.isArray(rawToolCalls)) {
@@ -221,8 +203,14 @@ export function useQuickChat(
setSessionsLoading(true);
try {
const data = await fetchChatSessions(projectId, "active");
const existingSession = findMatchingSession(data.sessions, target);
const { session: existingSession } = await fetchResumeChatSession(
{
agentId: target.agentId,
modelProvider: target.modelProvider,
modelId: target.modelId,
},
projectId,
);
if (existingSession) {
setActiveSession(existingSession);

View File

@@ -12,7 +12,7 @@ import { createHmac } from "node:crypto";
import { createApiRoutes } from "./routes.js";
import { GitHubClient } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
import type { TaskStore, TaskAttachment, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
import type { TaskStore, TaskAttachment, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult, ChatSession, ChatMessage } from "@fusion/core";
import type { TaskDetail } from "@fusion/core";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
import { __resetBatchImportRateLimiter, __setCreateFnAgentForRefine } from "./routes.js";
@@ -17158,3 +17158,130 @@ describe("Agent stale task-link sanitization", () => {
expect(testAgent.taskId).toBe(taskId);
});
});
describe("GET /api/chat/sessions lookup=resume", () => {
function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" | "agentId">): ChatSession {
const now = new Date().toISOString();
return {
id: overrides.id,
agentId: overrides.agentId,
title: overrides.title ?? null,
status: overrides.status ?? "active",
projectId: overrides.projectId ?? null,
modelProvider: overrides.modelProvider ?? null,
modelId: overrides.modelId ?? null,
createdAt: overrides.createdAt ?? now,
updatedAt: overrides.updatedAt ?? now,
};
}
function buildChatApp(overrides?: {
matchedSession?: ChatSession;
lastMessage?: Pick<ChatMessage, "sessionId" | "content" | "createdAt">;
}) {
const store = createMockStore();
const matchedSession = overrides?.matchedSession;
const chatStore = {
listSessions: vi.fn().mockReturnValue([]),
findLatestActiveSessionForTarget: vi.fn().mockReturnValue(matchedSession),
getLastMessageForSessions: vi.fn().mockImplementation((sessionIds: string[]) => {
const map = new Map<string, ChatMessage>();
if (overrides?.lastMessage && sessionIds.includes(overrides.lastMessage.sessionId)) {
const now = new Date().toISOString();
map.set(overrides.lastMessage.sessionId, {
id: "msg-1",
sessionId: overrides.lastMessage.sessionId,
role: "assistant",
content: overrides.lastMessage.content,
thinkingOutput: null,
metadata: null,
createdAt: overrides.lastMessage.createdAt ?? now,
});
}
return map;
}),
};
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { chatStore } as any));
return { app, chatStore };
}
it("returns only the targeted matched session when lookup=resume", async () => {
const matchedSession = makeSession({
id: "chat-match",
agentId: "agent-1",
projectId: "proj-1",
modelProvider: "openai",
modelId: "gpt-4o",
});
const { app, chatStore } = buildChatApp({
matchedSession,
lastMessage: {
sessionId: matchedSession.id,
content: "Most recent assistant reply",
createdAt: new Date().toISOString(),
},
});
const res = await GET(
app,
"/api/chat/sessions?lookup=resume&projectId=proj-1&agentId=agent-1&modelProvider=openai&modelId=gpt-4o",
);
expect(res.status).toBe(200);
expect(chatStore.findLatestActiveSessionForTarget).toHaveBeenCalledWith({
projectId: "proj-1",
agentId: "agent-1",
modelProvider: "openai",
modelId: "gpt-4o",
});
expect(chatStore.listSessions).not.toHaveBeenCalled();
expect(res.body.sessions).toHaveLength(1);
expect(res.body.sessions[0].id).toBe("chat-match");
expect(res.body.sessions[0].lastMessagePreview).toBe("Most recent assistant reply");
});
it("returns 400 when modelProvider/modelId are not both provided", async () => {
const { app } = buildChatApp();
const res = await GET(app, "/api/chat/sessions?lookup=resume&projectId=proj-1&agentId=agent-1&modelProvider=openai");
expect(res.status).toBe(400);
expect(res.body.error).toContain("Both modelProvider and modelId must be provided together");
});
it("returns 400 when lookup=resume is missing agentId", async () => {
const { app } = buildChatApp();
const res = await GET(app, "/api/chat/sessions?lookup=resume&projectId=proj-1");
expect(res.status).toBe(400);
expect(res.body.error).toContain("agentId is required when lookup=resume");
});
it("preserves list behavior when lookup parameter is absent", async () => {
const store = createMockStore();
const listedSession = makeSession({ id: "chat-listed", agentId: "agent-1" });
const chatStore = {
listSessions: vi.fn().mockReturnValue([listedSession]),
findLatestActiveSessionForTarget: vi.fn(),
getLastMessageForSessions: vi.fn().mockReturnValue(new Map()),
};
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { chatStore } as any));
const res = await GET(app, "/api/chat/sessions?status=active&agentId=agent-1");
expect(res.status).toBe(200);
expect(chatStore.listSessions).toHaveBeenCalledWith({ status: "active", agentId: "agent-1" });
expect(chatStore.findLatestActiveSessionForTarget).not.toHaveBeenCalled();
expect(res.body.sessions).toHaveLength(1);
expect(res.body.sessions[0].id).toBe("chat-listed");
});
});

View File

@@ -9608,17 +9608,46 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw internalError("Chat store not available");
}
const { projectId, status, agentId } = req.query as {
const { projectId, status, agentId, lookup, modelProvider, modelId } = req.query as {
projectId?: string;
status?: string;
agentId?: string;
lookup?: string;
modelProvider?: string;
modelId?: string;
};
const sessions = chatStore.listSessions({
...(projectId && { projectId }),
...(status && { status: status as "active" | "archived" }),
...(agentId && { agentId }),
});
const isResumeLookup = lookup === "resume";
const hasModelProvider = typeof modelProvider === "string" && modelProvider.trim().length > 0;
const hasModelId = typeof modelId === "string" && modelId.trim().length > 0;
if (hasModelProvider !== hasModelId) {
throw badRequest("Both modelProvider and modelId must be provided together, or neither should be provided");
}
if (isResumeLookup && (!agentId || !agentId.trim())) {
throw badRequest("agentId is required when lookup=resume");
}
const sessions = isResumeLookup
? (() => {
const matched = chatStore.findLatestActiveSessionForTarget({
agentId: agentId!.trim(),
...(projectId && { projectId }),
...(hasModelProvider && hasModelId
? {
modelProvider: modelProvider!.trim(),
modelId: modelId!.trim(),
}
: {}),
});
return matched ? [matched] : [];
})()
: chatStore.listSessions({
...(projectId && { projectId }),
...(status && { status: status as "active" | "archived" }),
...(agentId && { agentId }),
});
// Enrich sessions with last message preview
if (sessions.length > 0) {