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:
7
.changeset/fn-047-task-chat-context-project-scope.md
Normal file
7
.changeset/fn-047-task-chat-context-project-scope.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user