diff --git a/.changeset/fn-047-task-chat-context-project-scope.md b/.changeset/fn-047-task-chat-context-project-scope.md new file mode 100644 index 0000000000..1e1e9ffe91 --- /dev/null +++ b/.changeset/fn-047-task-chat-context-project-scope.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep task Chat context bound to the selected project when its engine is unavailable. +category: fix +dev: Request-scoped ChatManager resolution now retains the canonical TaskStore and ChatStore pair. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d4266a8f9f..bc1e6bbd2d 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -248,7 +248,7 @@ On mobile viewports, the Right Dock never renders. The compact Header actions an ## Task-detail Chat -Task-detail **Chat** uses the project’s configured Direct Chat default model and thinking level rather than the task’s planning model. It remains task-aware: the server builds the task definition, dependencies, activity, metrics, steering, and refinement context, and the existing `task-planner:` session keeps one transcript per task. The composer exposes the same model and thinking controls as Direct Chat; model choices remain model-targeted and do not replace the synthetic task-scoped permission contract. Changing the project default does not hide history, and the next explicit send applies the current target to the existing idle session. There is no separate planner-model lane for this conversation. +Task-detail **Chat** uses the project’s configured Direct Chat default model and thinking level rather than the task’s planning model. It remains task-aware: the server builds the task definition, dependencies, activity, metrics, steering, and refinement context, and the existing `task-planner:` session keeps one transcript per task. Task context always comes from the selected project’s authoritative store, including while that project’s engine has not started or is unavailable; another project’s task with the same ID cannot supply its context. The composer exposes the same model and thinking controls as Direct Chat; model choices remain model-targeted and do not replace the synthetic task-scoped permission contract. Changing the project default does not hide history, and the next explicit send applies the current target to the existing idle session. There is no separate planner-model lane for this conversation. ## Chat message editing and rewind diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 91a886d52a..75a15ccba1 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -2117,11 +2117,13 @@ describe("ChatManager.sendMessage", () => { it("adds rich task context and steering tools for synthetic task planner chat sessions", async () => { mockChatStore.getSession.mockReturnValue({ id: "chat-001", - agentId: "task-planner:FN-7310", + agentId: "task-planner:TEST-002", status: "active", /* - FNXC:TaskChatDefaultModel 2026-08-19-12:12: - The persisted synthetic task session carries the Direct Chat target into ChatManager. This test keeps the task context and scoped tool assertions alongside the model-loop precedence check. + FNXC:TaskChatProjectContext 2026-08-19-17:27: + The persisted synthetic task session carries the Direct Chat target into ChatManager. This + production send path must read TEST-002 only from its selected project store, never a + same-ID decoy held by the dashboard default store. */ modelProvider: "openai", modelId: "gpt-direct", @@ -2140,19 +2142,24 @@ describe("ChatManager.sendMessage", () => { __setCreateResolvedAgentSession(createResolvedSession as any); const taskStore = { - getTask: vi.fn().mockResolvedValue({ - id: "FN-7310", - title: "Add planner chat", - description: "Short list description should not replace the task prompt", - prompt: "# PROMPT.md\n\nImplement the planner-model Chat tab from the detailed task plan.", - column: "todo", - status: "planning", - currentStep: 0, - dependencies: ["FN-7309"], - steps: [{ title: "Polish", status: "in-progress" }], - comments: [{ text: "User wants planner chat", author: "user" }], - steeringComments: [{ text: "Keep Activity intact", author: "user" }], - log: [{ level: "info", message: "Activity transcript loaded" }], + getTask: vi.fn(async (id: string) => { + if (id === "TEST-002") { + return { + id, + title: "Secondary project task", + description: "Selected-project description", + prompt: "# PROMPT.md\n\nUse the secondary project's authoritative task context.", + column: "todo", + status: "planning", + currentStep: 0, + dependencies: ["FN-7309"], + steps: [{ title: "Polish", status: "in-progress" }], + comments: [{ text: "User wants planner chat", author: "user" }], + steeringComments: [{ text: "Keep Activity intact", author: "user" }], + log: [{ level: "info", message: "Activity transcript loaded" }], + }; + } + return { id, title: "Selected-project dependency", column: "done" }; }), getSettings: vi.fn().mockResolvedValue({}), }; @@ -2173,10 +2180,12 @@ describe("ChatManager.sendMessage", () => { expect(createOptions.defaultModelId).toBe("gpt-direct"); expect(createOptions.defaultThinkingLevel).toBe("high"); expect(createOptions.systemPrompt).toContain("## Task Planner Chat Context"); - expect(createOptions.systemPrompt).toContain("Task ID: FN-7310"); - expect(createOptions.systemPrompt).toContain("Title: Add planner chat"); + expect(createOptions.systemPrompt).toContain("Task ID: TEST-002"); + expect(createOptions.systemPrompt).toContain("Title: Secondary project task"); expect(createOptions.systemPrompt).toContain("Prompt:\n# PROMPT.md"); - expect(createOptions.systemPrompt).toContain("Implement the planner-model Chat tab from the detailed task plan."); + expect(createOptions.systemPrompt).toContain("Use the secondary project's authoritative task context."); + expect(createOptions.systemPrompt).not.toContain("Task context could not be loaded"); + expect(createOptions.systemPrompt).not.toContain("Default-project decoy content"); expect(createOptions.systemPrompt).toContain("Dependencies:\n- FN-7309:"); expect(createOptions.systemPrompt).toContain("Progress: step 1 of 1"); expect(createOptions.systemPrompt).toContain("Current step: Polish: in-progress"); @@ -2205,7 +2214,7 @@ describe("ChatManager.sendMessage", () => { role: "user", content: expect.stringContaining("Task Planner Chat Context"), })); - expect(taskStore.getTask).toHaveBeenNthCalledWith(1, "FN-7310", { activityLogLimit: 20 }); + expect(taskStore.getTask).toHaveBeenNthCalledWith(1, "TEST-002", { activityLogLimit: 20 }); expect(taskStore.getTask).toHaveBeenCalledWith("FN-7309"); }); diff --git a/packages/dashboard/src/__tests__/chat-project-services.test.ts b/packages/dashboard/src/__tests__/chat-project-services.test.ts index 1a747053b1..0afbc52c53 100644 --- a/packages/dashboard/src/__tests__/chat-project-services.test.ts +++ b/packages/dashboard/src/__tests__/chat-project-services.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { __resetScopedChatManagerCache, getOrCreateScopedChatManager, + resolveProjectChatContext, } from "../chat-project-services.js"; function createStore(fusionDir = "/tmp/fusion-project") { @@ -53,6 +54,23 @@ describe("project-scoped ChatManager cache", () => { expect((upgradedManager as any).messageStore).toBe(messageStore); }); + it("keeps a request's secondary store when its engine is unavailable", async () => { + const defaultStore = createStore("/tmp/default/.fusion"); + const secondaryStore = createStore("/tmp/secondary/.fusion"); + const defaultChatStore = createChatStore(); + + const context = await resolveProjectChatContext({ + projectId: "secondary-project", + defaultStore, + defaultChatStore, + requestStore: secondaryStore, + engineManager: { getEngine: vi.fn(() => undefined) } as any, + }); + + expect(context.store).toBe(secondaryStore); + expect(context.chatStore).not.toBe(defaultChatStore); + }); + it("preserves plugin-runner refresh semantics alongside MessageStore refresh", () => { const store = createStore(); const chatStore = createChatStore(); diff --git a/packages/dashboard/src/__tests__/routes-chat-cancellation.test.ts b/packages/dashboard/src/__tests__/routes-chat-cancellation.test.ts index cd904a8107..6030b840c8 100644 --- a/packages/dashboard/src/__tests__/routes-chat-cancellation.test.ts +++ b/packages/dashboard/src/__tests__/routes-chat-cancellation.test.ts @@ -30,7 +30,7 @@ function makeApp(manager: { cancelGeneration: ReturnType }) { chatLogger: { error: vi.fn(), warn: vi.fn(), log: vi.fn() } as any, getProjectIdFromRequest: () => undefined, getScopedStore: vi.fn(), - getProjectContext: vi.fn(), + getProjectContext: vi.fn().mockResolvedValue({ store: {}, projectId: "project-a", engine: undefined }), getProjectPluginLoader: vi.fn(), prioritizeProjectsForCurrentDirectory: (projects: unknown[]) => projects, emitRemoteRouteDiagnostic: vi.fn(), diff --git a/packages/dashboard/src/chat-project-services.ts b/packages/dashboard/src/chat-project-services.ts index 9bc5fed40f..4f74707da7 100644 --- a/packages/dashboard/src/chat-project-services.ts +++ b/packages/dashboard/src/chat-project-services.ts @@ -31,8 +31,26 @@ export async function resolveProjectChatContext(options: { defaultStore: TaskStore; defaultChatStore?: ChatStore; engineManager?: ProjectEngineManager; + requestStore?: TaskStore; }): Promise<{ store: TaskStore; chatStore: ChatStore }> { - const { projectId, defaultStore, defaultChatStore, engineManager } = options; + const { projectId, defaultStore, defaultChatStore, engineManager, requestStore } = options; + + /* + FNXC:TaskChatProjectContext 2026-08-19-17:25: + A request's canonical project store is authoritative for task Chat. A secondary project can be + reachable before its engine is live, so substituting the dashboard default store here would make + its synthetic task session load another project's task context or report it missing. + */ + if (requestStore) { + return { + store: requestStore, + chatStore: getOrCreateScopedChatStore( + requestStore, + requestStore === defaultStore ? defaultChatStore : undefined, + ), + }; + } + if (!projectId) { return { store: defaultStore, diff --git a/packages/dashboard/src/routes/register-chat-room-routes.ts b/packages/dashboard/src/routes/register-chat-room-routes.ts index 584f0f3cf2..845e2b14aa 100644 --- a/packages/dashboard/src/routes/register-chat-room-routes.ts +++ b/packages/dashboard/src/routes/register-chat-room-routes.ts @@ -95,11 +95,18 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext, deps: ChatRoomRout return { chatStore, chatManager }; } + const requestContext = await getProjectContext(req); const { store: scopedStore, chatStore } = await resolveProjectChatContext({ projectId: roomProjectId, defaultStore: ctx.store, defaultChatStore: options?.chatStore, engineManager: options?.engineManager, + /* + FNXC:TaskChatProjectContext 2026-08-19-17:27: + A room request already has the canonical selected-project store; preserve that + store/chat pair when its explicit room scope agrees with the request. + */ + requestStore: requestContext.projectId === roomProjectId ? requestContext.store : undefined, }); const engine = options.engineManager.getEngine(roomProjectId); const chatManager = await createProjectScopedChatManager({ diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index 9b99a93b1f..b52dedabc8 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -112,32 +112,33 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): // ── Per-project store / manager resolution ─────────────────────────────────── - async function resolveScopedChatStore(projectId: string | undefined) { - return resolveProjectChatContext({ - projectId, + async function resolveScopedChatStore(req: import("express").Request) { + const projectContext = await getProjectContext(req); + const chatContext = await resolveProjectChatContext({ + projectId: projectContext.projectId, defaultStore: store, defaultChatStore: options?.chatStore, engineManager: options?.engineManager, + requestStore: projectContext.store, }); + return { ...chatContext, projectId: projectContext.projectId, engine: projectContext.engine }; } - async function resolveScopedChatManager(projectId: string | undefined) { + async function resolveScopedChatManager(req: import("express").Request) { + const { store: scopedStore, chatStore, projectId, engine: contextEngine } = await resolveScopedChatStore(req); if (!projectId) { if (!options?.chatManager) throw new ApiError(503, "Chat manager not available"); return options.chatManager; } /* FNXC:GrokAcp 2026-07-11-17:00: - Chat list/create use resolveProjectChatContext, which falls back to the host - default store when no engine is running for the project (nested dashboard / - lockfile-blocked engines). ChatManager must use that same store/chatStore - pair — getOrCreateProjectStore alone pointed at a different fusion dir, so - sessions visible in the UI 404'd on sendMessage ("Chat session not found"). + Chat list/create and send resolve the request's canonical scoped store before + constructing the manager. This keeps engine-unavailable secondary projects on + their own ChatStore/TaskStore pair instead of borrowing the host default store. Prefer the engine plugin runner when available; otherwise the host runner (e.g. Grok ACP 0.2) so CLI runtimes still resolve. */ - const { store: scopedStore, chatStore } = await resolveScopedChatStore(projectId); - const engine = options?.engineManager?.getEngine(projectId); + const engine = contextEngine ?? options?.engineManager?.getEngine(projectId); const projectPluginRunner = engine?.getPluginRunner?.(); const pluginRunner = projectPluginRunner ?? options?.pluginRunner; return getOrCreateScopedChatManager(scopedStore, chatStore, pluginRunner, Boolean(projectPluginRunner), engine?.getMessageStore()); @@ -198,7 +199,13 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): const { modelProvider, modelId } = validateModelPair(req.body?.modelProvider, req.body?.modelId); const thinkingLevel = validateThinkingLevel(req.body?.thinkingLevel); const { store: scopedStore, projectId } = await getProjectContext(req); - const { chatStore } = await resolveScopedChatStore(projectId); + const { chatStore } = await resolveProjectChatContext({ + projectId, + defaultStore: store, + defaultChatStore: options?.chatStore, + engineManager: options?.engineManager, + requestStore: scopedStore, + }); const task = await scopedStore.getTask(taskId).catch(() => null); if (!task) { throw notFound(`Task ${taskId} not found`); @@ -284,7 +291,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): router.get("/chat/tags", rateLimit(RATE_LIMITS.api), async (req, res) => { try { const { projectId } = await getProjectContext(req); - const { chatStore } = await resolveScopedChatStore(projectId); + const { chatStore } = await resolveScopedChatStore(req); res.json({ tags: await chatStore.listTags(projectId ?? null) }); } catch (err) { rethrowAsApiError(err, "Failed to list chat tags"); } }); @@ -293,7 +300,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): try { if (typeof req.body?.name !== "string") throw badRequest("name must be a string"); const { projectId } = await getProjectContext(req); - const { chatStore } = await resolveScopedChatStore(projectId); + const { chatStore } = await resolveScopedChatStore(req); const tag = await chatStore.createTag({ name: req.body.name, projectId: projectId ?? null }); res.status(201).json({ tag }); } catch (err) { @@ -308,7 +315,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): try { if (typeof req.body?.name !== "string") throw badRequest("name must be a string"); const { projectId } = await getProjectContext(req); - const { chatStore } = await resolveScopedChatStore(projectId); + const { chatStore } = await resolveScopedChatStore(req); const tag = await chatStore.renameTag(String(req.params.id), projectId ?? null, { name: req.body.name }); if (!tag) throw notFound("Chat tag not found"); res.json({ tag }); @@ -323,7 +330,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): router.delete("/chat/tags/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => { try { const { projectId } = await getProjectContext(req); - const { chatStore } = await resolveScopedChatStore(projectId); + const { chatStore } = await resolveScopedChatStore(req); if (!await chatStore.deleteTag(String(req.params.id), projectId ?? null)) throw notFound("Chat tag not found"); res.json({ success: true }); } catch (err) { if (err instanceof ApiError) throw err; rethrowAsApiError(err, "Failed to delete chat tag"); } @@ -361,7 +368,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): q?: string; titleOnly?: string; }; - const { store: scopedStore, chatStore } = await resolveScopedChatStore(projectId); + const { store: scopedStore, chatStore } = await resolveScopedChatStore(req); const hasSearchQuery = typeof q === "string" && q.trim().length > 0; const isTitleOnly = titleOnly === "true" || !hasSearchQuery; const isContentSearch = hasSearchQuery && !isTitleOnly; @@ -447,7 +454,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): // Batch-gather generating session IDs to avoid N+1 calls const resolvedChatManager = projectId - ? await resolveScopedChatManager(projectId).catch(() => options?.chatManager) + ? await resolveScopedChatManager(req).catch(() => options?.chatManager) : options?.chatManager; const generatingIds = resolvedChatManager?.getGeneratingSessionIds?.() ?? []; const generatingSet = new Set(generatingIds); @@ -493,7 +500,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): try { // Get project context to scope the session and resolve agent from the correct store const { store: scopedStore, projectId } = await getProjectContext(req); - const { chatStore } = await resolveScopedChatStore(projectId); + const { chatStore } = await resolveScopedChatStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir(), asyncLayer: scopedStore.getAsyncLayer() ?? undefined }); await agentStore.init(); @@ -581,7 +588,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): */ router.get("/chat/sessions/:id", async (req, res) => { try { - const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined); + const { chatStore } = await resolveScopedChatStore(req); const sessionId = String(req.params.id); const session = await chatStore.getSession(sessionId); @@ -590,7 +597,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): } const enriched: EnrichedChatSession = session; - const chatManager = await resolveScopedChatManager(req.query.projectId as string | undefined).catch(() => options?.chatManager); + const chatManager = await resolveScopedChatManager(req).catch(() => options?.chatManager); enriched.isGenerating = chatManager?.isGenerating?.(sessionId) ?? false; res.json({ session: enriched }); @@ -636,7 +643,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): */ router.patch("/chat/sessions/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => { try { - const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined); + const { chatStore } = await resolveScopedChatStore(req); const sessionId = String(req.params.id); const { @@ -751,7 +758,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): */ router.delete("/chat/sessions/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => { try { - const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined); + const { chatStore } = await resolveScopedChatStore(req); const sessionId = String(req.params.id); const deleted = await chatStore.deleteSession(sessionId); @@ -775,7 +782,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): */ router.get("/chat/sessions/:id/messages", async (req, res) => { try { - const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined); + const { chatStore } = await resolveScopedChatStore(req); const sessionId = String(req.params.id); @@ -827,7 +834,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): router.post("/chat/sessions/:id/attachments", rateLimit(RATE_LIMITS.mutation), uploadChatAttachment, async (req, res) => { try { - const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined); + const { chatStore } = await resolveScopedChatStore(req); const sessionId = String(req.params.id); const session = await chatStore.getSession(sessionId); @@ -893,8 +900,8 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): */ router.get("/chat/sessions/:id/stream", rateLimit(RATE_LIMITS.sse), async (req, res) => { try { - const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined); - const chatManager = await resolveScopedChatManager(req.query.projectId as string | undefined); + const { chatStore } = await resolveScopedChatStore(req); + const chatManager = await resolveScopedChatManager(req); const sessionId = String(req.params.id); const session = await chatStore.getSession(sessionId); @@ -987,8 +994,14 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): let chatManager: Awaited> | undefined; const sessionId = String(req.params.id); try { - const projectId = req.query.projectId as string | undefined; - const { chatStore } = await resolveScopedChatStore(projectId); + const { store: scopedStore, projectId } = await getProjectContext(req); + const { chatStore } = await resolveProjectChatContext({ + projectId, + defaultStore: store, + defaultChatStore: options?.chatStore, + engineManager: options?.engineManager, + requestStore: scopedStore, + }); const body = (req.body ?? {}) as { content?: string; @@ -1043,7 +1056,6 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): throw badRequest("modelProvider and modelId must both be provided or neither"); } - const { store: scopedStore } = await getProjectContext(req); const uploadedAttachments = uploadedFiles.length > 0 ? await Promise.all(uploadedFiles.map((file) => persistChatAttachment(file, scopedStore.getRootDir(), sessionId))) : undefined; @@ -1053,7 +1065,15 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): // Resolve per-project ChatManager before opening the SSE stream so // failures (e.g. project DB cannot be opened) produce a proper HTTP error. - chatManager = await resolveScopedChatManager(projectId); + const engine = projectId ? options?.engineManager?.getEngine(projectId) : undefined; + const projectPluginRunner = engine?.getPluginRunner?.(); + chatManager = getOrCreateScopedChatManager( + scopedStore, + chatStore, + projectPluginRunner ?? options?.pluginRunner, + Boolean(projectPluginRunner), + engine?.getMessageStore(), + ); // The internal limiter is shared with GET stream subscribers. Keep its rejection // before headers so a replacement cannot be accepted without a prepared send. @@ -1168,7 +1188,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): */ router.post("/chat/sessions/:id/cancel", rateLimit(RATE_LIMITS.mutation), async (req, res) => { try { - const chatManager = await resolveScopedChatManager(req.query.projectId as string | undefined); + const chatManager = await resolveScopedChatManager(req); const sessionId = String(req.params.id); // FNXC:ChatCancellation 2026-08-18-21:52: // Await cancellation so clients only reconcile or dequeue follow-up sends after @@ -1189,7 +1209,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): */ router.delete("/chat/sessions/:id/messages/:messageId", rateLimit(RATE_LIMITS.mutation), async (req, res) => { try { - const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined); + const { chatStore } = await resolveScopedChatStore(req); const sessionId = String(req.params.id); const messageId = String(req.params.messageId);