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