diff --git a/docs/architecture.md b/docs/architecture.md index 14a872c68..a8e37dc0f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -401,6 +401,11 @@ Key server capabilities: - Project-scoped: resolves project context from query param or engine manager - **Chat streaming**: `/api/chat/sessions/:id/messages` (`routes.ts` + `chat.ts`) - Streams assistant responses as SSE events for chat sessions +- **Chat session queries**: `/api/chat/sessions` (`routes.ts`) + - Existing list behavior is unchanged (`status=active|archived|all` returns an array) + - Quick Chat resume uses targeted lookup params: `agentId`, optional `modelProvider` + `modelId`, plus `resume=1` + - Validation requires `modelProvider` and `modelId` together; partial model pairs return `400` + - Targeted lookup returns only the newest matching active session (or `null`) to avoid scanning every active session client-side - **Task log stream**: `/api/tasks/:id/logs/stream` (`server.ts`) - SSE endpoint for live task log streaming with project scope resolution - **Dev-server stream**: `/api/dev-server/logs/stream` (`dev-server-routes.ts`) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 60704eadd..a3dde5e2f 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -44,6 +44,9 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv - Controlled by the project setting `showQuickChatFAB` - Supports agent mentions (`@agent`) and file mentions - Uses the same model/provider infrastructure as full Chat view +- `switchSession()` resumes the existing thread for the selected target (agent-only or agent+model) +- `startFreshSession()` is the explicit new-thread action and always creates a new persisted session +- Resume lookups use a targeted session query instead of loading the full active session list first ## Mailbox View diff --git a/packages/core/src/__tests__/chat-store.test.ts b/packages/core/src/__tests__/chat-store.test.ts index 5bfb732b9..e709c370e 100644 --- a/packages/core/src/__tests__/chat-store.test.ts +++ b/packages/core/src/__tests__/chat-store.test.ts @@ -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); diff --git a/packages/core/src/chat-store.ts b/packages/core/src/chat-store.ts index 7a6ab91ce..99cdce159 100644 --- a/packages/core/src/chat-store.ts +++ b/packages/core/src/chat-store.ts @@ -203,6 +203,74 @@ export class ChatStore extends EventEmitter { 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. * diff --git a/packages/dashboard/app/api.ts b/packages/dashboard/app/api.ts index a154c4374..da69a502e 100644 --- a/packages/dashboard/app/api.ts +++ b/packages/dashboard/app/api.ts @@ -6557,6 +6557,45 @@ export function fetchChatSessions(projectId?: string, status?: string): Promise< return api(`/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(`/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 }, diff --git a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx index 7b00217d2..7cfba6366 100644 --- a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx @@ -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(); @@ -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(); @@ -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"); diff --git a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts index c358c2852..7b0d36a13 100644 --- a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts @@ -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 & Pick { 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[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) => { diff --git a/packages/dashboard/app/hooks/useQuickChat.ts b/packages/dashboard/app/hooks/useQuickChat.ts index 435c43dcd..8d10f187d 100644 --- a/packages/dashboard/app/hooks/useQuickChat.ts +++ b/packages/dashboard/app/hooks/useQuickChat.ts @@ -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 | 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); diff --git a/packages/dashboard/src/routes.test.ts b/packages/dashboard/src/routes.test.ts index 9b3e227cb..9670f0254 100644 --- a/packages/dashboard/src/routes.test.ts +++ b/packages/dashboard/src/routes.test.ts @@ -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 & Pick): 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; + }) { + 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(); + 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"); + }); +}); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 0bacca268..590f1dbe7 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -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) {