FN-047: preserve project-scoped Chat context

Keep task Chat bound to the selected project even when its engine is unavailable.

- Resolve request-scoped TaskStore and ChatStore pairs without falling back to the default project.
- Apply the project-safe resolution to task planner and room chat routes.
- Add regression coverage and document the selected-project context guarantee.

Files changed:
 .../fn-047-task-chat-context-project-scope.md      |  7 ++
 docs/dashboard-guide.md                            |  2 +-
 .../dashboard/src/__tests__/chat-manager.test.ts   | 49 +++++++-----
 .../src/__tests__/chat-project-services.test.ts    | 18 +++++
 .../src/__tests__/routes-chat-cancellation.test.ts |  2 +-
 packages/dashboard/src/chat-project-services.ts    | 20 ++++-
 .../src/routes/register-chat-room-routes.ts        |  7 ++
 .../dashboard/src/routes/register-chat-routes.ts   | 86 +++++++++++++---------
 8 files changed, 135 insertions(+), 56 deletions(-)

Fusion-Task-Id: FN-047

Fusion-Task-Lineage: 008eefad-e495-40ba-9243-d46ec913c9db

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-19 17:41:21 +00:00
parent 7563fcfe1b
commit ef22fa478a
8 changed files with 135 additions and 56 deletions

View File

@@ -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.

View File

@@ -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:<taskId>` 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:<taskId>` 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

View File

@@ -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");
});

View File

@@ -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();

View File

@@ -30,7 +30,7 @@ function makeApp(manager: { cancelGeneration: ReturnType<typeof vi.fn> }) {
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(),

View File

@@ -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,

View File

@@ -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({

View File

@@ -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<ReturnType<typeof resolveScopedChatManager>> | 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);