FN-9209: Gate chat focus behind an experimental flag

Make per-conversation chat memory focus opt-in while preserving stored focus values for compatibility.

- Add the chatFocus experimental setting, exports, documentation, and release changeset.
- Hide and disable focus controls and commands across chat and task-planner composers when the flag is off.
- Apply persisted focus scopes to memory recall only when enabled, with regression coverage for both flag states.

Files changed:
 .changeset/fn-9209-chat-focus-experimental-flag.md |   7 ++
 docs/memory-backend-integration.md                 |   6 +-
 docs/settings-reference.md                         |   3 +-
 .../core/src/__tests__/settings-defaults.test.ts   |   9 +-
 packages/core/src/config/experimental-features.ts  |   7 ++
 packages/core/src/index.gate.ts                    |   2 +-
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/types.ts                         |   7 ++
 packages/dashboard/app/components/ChatView.tsx     |  37 ++++---
 .../dashboard/app/components/SettingsModal.tsx     |   1 +
 .../app/components/TaskPlannerChatTab.tsx          |  62 ++++++++---
 .../__tests__/ChatView.chat-focus-flag.test.tsx    | 122 +++++++++++++++++++++
 .../__tests__/ChatView.focus-chip.test.tsx         |   2 +-
 .../TaskPlannerChatTab.chat-focus-flag.test.tsx    | 108 ++++++++++++++++++
 .../__tests__/TaskPlannerChatTab.test.tsx          |   5 +-
 .../app/components/__tests__/chat-commands.test.ts |   7 +-
 .../app/components/__tests__/focus-command.test.ts |  13 ++-
 packages/dashboard/app/components/chat-commands.ts |  11 ++
 .../chat-memory-focus-reachability.test.ts         |  16 ++-
 packages/dashboard/src/chat.ts                     |  30 ++---
 20 files changed, 394 insertions(+), 63 deletions(-)

Fusion-Task-Id: FN-9209

Fusion-Task-Lineage: d58296be-a5f9-4b8c-bf4a-03f7f9100efe

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-23 21:47:40 -07:00
parent ccf7ff114f
commit 68c466a6ab
20 changed files with 394 additions and 63 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Make per-conversation chat memory focus an opt-in experimental feature.
category: feature
dev: Use experimentalFeatures.chatFocus to enable the composer chip, /focus command, and recall scoping.

View File

@@ -70,8 +70,10 @@ are distinguishable by the discriminator tag.
## 5. Per-conversation memory focus (read-time scoping)
Fusion implements **conversation focus** so a recall hit is scoped to the conversation that
produced it. The focus is persisted per chat session via the schema migration
Fusion implements **conversation focus** as an opt-in feature. Enable
`experimentalFeatures.chatFocus` in **Settings → Experimental Features** to show its composer
control and apply its recall scope; the flag is default off, and persisted focus values are inert
until it is enabled. The focus is persisted per chat session via the schema migration
**`0059_chat_session_memory_focus.sql`** (`SCHEMA_BASELINE_VERSION` = `0059`), which adds a
`memory_focus` column to the chat-session table.

View File

@@ -213,7 +213,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
| `researchGlobalMaxSearchResults` | `number` | `undefined` | Maximum search results per provider query. |
| `researchGlobalFetchTimeoutMs` | `number` | `30000` | Timeout for individual HTTP fetches in milliseconds. |
| `researchGlobalUserAgent` | `string` | `"FusionResearchBot/1.0"` | User-Agent header for HTTP requests made by research providers. |
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools); `experimentalFeatures.evalsView`, which gates Evals surfaces (dashboard view, Settings → Scheduled Evals, and scheduled-eval cron execution); and default-off `experimentalFeatures.ideationView`, which gates the top-level Ideation view (desktop sidebar/Header fallback and mobile More only). |
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools); `experimentalFeatures.evalsView`, which gates Evals surfaces (dashboard view, Settings → Scheduled Evals, and scheduled-eval cron execution); default-off `experimentalFeatures.ideationView`, which gates the top-level Ideation view (desktop sidebar/Header fallback and mobile More only); and default-off `experimentalFeatures.chatFocus`, which gates the chat composer Focus chip, `/focus` slash command, and server-side recall scoping. Persisted chat focus is inert while this flag is off. |
| `remoteAccess` | `RemoteAccessSettings` | `{ activeProvider: null, providers: {...}, tokenStrategy: {...}, lifecycle: {...} }` | Global-scoped remote access provider + token strategy configuration used by Remote Access routes and tunnel lifecycle controls. |
| `mcpServers` | `McpServersSettings` | `{ enabled: false, servers: [] }` | Global MCP server declarations shared across projects. Project `mcpServers` can enable/disable the effective set, override a same-named global server, or disable a global server with a same-named `enabled:false` entry. Sensitive env/header/token values must be `{ secretRef, scope }` references to Fusion-managed secrets, never plaintext. |
| `worktrunk` | `WorktrunkSettings` | `{ enabled: false, binaryPath: undefined, installedBinaryPath: undefined, onFailure: "fail" }` | Global defaults for worktrunk integration. Merged field-by-field with project `worktrunk` values; project values override global values for matching fields. |
@@ -1706,6 +1706,7 @@ Common built-in dashboard/runtime flags include:
- `researchView`
- `evalsView` (gates Evals dashboard view, Settings → Scheduled Evals section, and scheduled-eval cron execution)
- `ideationView` (default off; gates the top-level Ideation view, which is mobile More-only and replaces the Command Center Ideation tab)
- `chatFocus` (default off; gates the chat Focus chip, `/focus` command, and server-side per-conversation recall scoping. A persisted focus topic is inert until enabled in Settings → Experimental Features.)
- `workflowGraphExecutor` (enables the workflow-IR interpreter path)
- `graphNativePostMerge` (**default-ON**; the graph is the sole owner of post-merge `optional-group` steps after a successful merge — the legacy merger-owned post-merge path was deleted. Post-merge failures are non-blocking. See [Workflow Steps → Execution Phases](./workflow-steps.md#execution-phases))
- `workflowInterpreterDualObserve` (retired/inert; stale persisted `true` values are forced OFF and must not reactivate hidden shadow observation)

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { CONSECUTIVE_TOOL_FAILURE_RETRY_THRESHOLD, DEFAULT_CONSECUTIVE_TOOL_FAILURE_RETRY_BACKOFF_MS, DEFAULT_MAX_CONSECUTIVE_TOOL_FAILURE_RETRIES, DEFAULT_MAX_AUTO_MERGE_RETRIES, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries } from "../tasks/in-review-stall.js";
import { isExperimentalFeatureEnabled } from "../config/experimental-features.js";
import { CHAT_FOCUS_FLAG, isExperimentalFeatureEnabled } from "../config/experimental-features.js";
import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalOnlySettingsKey, isProjectSettingsKey } from "../config/settings-schema.js";
import {
__resetLegacyCwdMainWarningForTests,
@@ -71,6 +71,13 @@ describe("settings defaults invariants", () => {
expect(isExperimentalFeatureEnabled({ experimentalFeatures: { workflowInterpreterDualObserve: true } }, "workflowInterpreterDualObserve")).toBe(false);
});
it("keeps chat focus experimental and default off", () => {
expect(isExperimentalFeatureEnabled(undefined, CHAT_FOCUS_FLAG)).toBe(false);
expect(isExperimentalFeatureEnabled({ experimentalFeatures: {} }, CHAT_FOCUS_FLAG)).toBe(false);
expect(isExperimentalFeatureEnabled({ experimentalFeatures: { chatFocus: false } }, CHAT_FOCUS_FLAG)).toBe(false);
expect(isExperimentalFeatureEnabled({ experimentalFeatures: { chatFocus: true } }, CHAT_FOCUS_FLAG)).toBe(true);
});
it("defaults maxAutoMergeRetries to the historical project-scoped cap", () => {
expect(DEFAULT_PROJECT_SETTINGS.maxAutoMergeRetries).toBe(DEFAULT_MAX_AUTO_MERGE_RETRIES);
expect("maxAutoMergeRetries" in DEFAULT_GLOBAL_SETTINGS).toBe(false);

View File

@@ -44,6 +44,13 @@ WORKFLOW_INTERPRETER_DUAL_OBSERVE_FLAG read plumbing (named constant +
*/
export const GRAPH_NATIVE_POST_MERGE_FLAG = "graphNativePostMerge" as const;
/*
FNXC:ChatMemoryFocus 2026-08-24-04:21:
Per-conversation memory Focus is opt-in: the chip, /focus command, and recall scoping stay off
until operators enable this flag. Persisted focus topics remain inert while it is disabled.
*/
export const CHAT_FOCUS_FLAG = "chatFocus" as const;
export function isExperimentalFeatureEnabled(
settings: Pick<Settings, "experimentalFeatures"> | undefined,
key: string,

View File

@@ -2151,7 +2151,7 @@ export type {
ResearchCancellationState,
} from "./research/research-types.js";
export { isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG } from "./config/experimental-features.js";
export { isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG, CHAT_FOCUS_FLAG } from "./config/experimental-features.js";
export {
POST_MERGE_VERIFICATION_GROUP_ID,
postMergeOptionalGroupNode,

View File

@@ -2389,7 +2389,7 @@ export type {
ResearchCancellationState,
} from "./research/research-types.js";
export { isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG } from "./config/experimental-features.js";
export { isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG, CHAT_FOCUS_FLAG } from "./config/experimental-features.js";
export {
DEFAULT_MOBILE_NAV_PRIMARY_ITEMS,
MAX_MOBILE_NAV_PRIMARY_ITEMS,

View File

@@ -1552,6 +1552,13 @@ export { PROMPT_KEY_CATALOG } from "./tasks/prompt-overrides.js";
// Re-exported here so the dashboard's `@fusion/core` → types.ts alias resolves
// client-side consumers (see packages/dashboard/vite.config.ts).
export { getErrorMessage } from "./process/error-message.js";
/*
FNXC:ChatMemoryFocus 2026-08-24-04:21:
Dashboard client imports resolve @fusion/core to this browser-safe leaf, so expose the pure
experimental flag reader here. Its Settings dependency is type-only and introduces no browser runtime cycle.
*/
export { isExperimentalFeatureEnabled, CHAT_FOCUS_FLAG } from "./config/experimental-features.js";
export {
resolveExecutionSettingsModel,
resolvePlanningSettingsModel,

View File

@@ -34,7 +34,7 @@ import { useChatUnread } from "../hooks/useChatUnread";
import { useComposerDictation } from "../hooks/useComposerDictation";
import { useViewportMode } from "./Header";
import { fetchSettings, fetchChatSession, updateGlobalSettings, type DiscoveredSkill } from "../api";
import { type Agent, type ChatTag, type Settings } from "@fusion/core";
import { isExperimentalFeatureEnabled, CHAT_FOCUS_FLAG, type Agent, type ChatTag, type Settings } from "@fusion/core";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { MicButton } from "./MicButton";
import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl";
@@ -70,7 +70,7 @@ import {
formatModelTag,
} from "./StandardChatSurface";
import { buildChatReportHandoff, type ChatReportHandoff } from "./chatReportHandoff";
import { CHAT_COMMANDS, matchChatCommand, filterChatCommands, getSlashTriggerMatch, type ChatCommand } from "./chat-commands";
import { matchChatCommand, filterChatCommands, getSlashTriggerMatch, selectChatCommands, type ChatCommand } from "./chat-commands";
import { useChatMessageLayout } from "../context/ChatMessageLayoutContext";
import {
createChatInputAutosizeController,
@@ -647,6 +647,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
};
}, [projectId]);
const resolvedDefaultThinkingLevel = chatSettings?.defaultThinkingLevel ?? "off";
const chatFocusEnabled = isExperimentalFeatureEnabled(chatSettings ?? undefined, CHAT_FOCUS_FLAG);
const selectedChatCommands = useMemo(() => selectChatCommands({ chatFocusEnabled }), [chatFocusEnabled]);
const chatDefaultTarget = useMemo(() => {
/*
FNXC:ChatModels 2026-07-12-20:45:
@@ -1128,8 +1130,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
// Chat surface never shows/dispatches them, so its skill-only behavior is unchanged.
const filteredCommands = useMemo(() => {
if (!chatCommandContext) return [] as ChatCommand[];
return filterChatCommands(skillFilter, CHAT_COMMANDS);
}, [chatCommandContext, skillFilter]);
return filterChatCommands(skillFilter, selectedChatCommands);
}, [chatCommandContext, skillFilter, selectedChatCommands]);
const skillMenuEntries = useMemo<SkillMenuEntry[]>(() => {
const commandEntries: SkillMenuEntry[] = filteredCommands.map((command) => ({
@@ -2051,7 +2053,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
if ((!trimmed && files.length === 0) || !activeSession) return;
if (chatCommandContext) {
const commandMatch = matchChatCommand(trimmed, CHAT_COMMANDS);
const commandMatch = matchChatCommand(trimmed, selectedChatCommands);
if (commandMatch) {
// FNXC:ChatMemoryFocus (RUFU-068): only agent-gated commands (steer) are
// refused without a running agent. /focus is a local session-setting command
@@ -2180,6 +2182,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
chatCommandContext,
isStreaming,
releaseSentAttachments,
selectedChatCommands,
t,
]);
@@ -3325,19 +3328,19 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
<Paperclip size={16} />
</button>
{/*
FNXC:ChatMemoryFocus 2026-08-13:
RUFU-068: per-conversation memory focus chip for direct chat sessions. Persists
on chat_sessions.memory_focus so it survives reconnect; recall scoping is server-side
(within-project read filter), never a client post-query filter. Only the direct composer
shows it — rooms have no per-conversation focus.
FNXC:ChatMemoryFocus 2026-08-24-04:21:
Per-conversation memory focus is opt-in. Hide its direct-session chip until Settings
enables experimentalFeatures.chatFocus; persisted values remain inert while hidden.
*/}
<ChatFocusSelector
sessionId={activeSession?.id ?? null}
projectId={projectId}
memoryFocus={resolvedChatFocus}
onPersist={(focus) => setChatFocusOverride(focus)}
addToast={addToast}
/>
{chatFocusEnabled && (
<ChatFocusSelector
sessionId={activeSession?.id ?? null}
projectId={projectId}
memoryFocus={resolvedChatFocus}
onPersist={(focus) => setChatFocusOverride(focus)}
addToast={addToast}
/>
)}
{/*
FNXC:Chat-ThinkingLevel 2026-07-16-00:34:
FN-8030: direct sessions retain model/agent targeting here, while room composers reuse

View File

@@ -519,6 +519,7 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
leftSidebarNav: "Left Sidebar Navigation",
sandbox: "Sandbox (command isolation)",
chatRooms: "Chat Rooms",
chatFocus: "Chat Focus (per-conversation memory recall)",
agentOnboarding: "Planning-style Agent Onboarding",
workflowInterpreterDualObserve: "Workflow Graph Engine — dual-observe parity (diagnostic)",
};

View File

@@ -1,6 +1,6 @@
import type { ChatInFlightGenerationState, ChatMessage, ResolvedModelSelection, Task, TaskDetail } from "@fusion/core";
import type { ChatInFlightGenerationState, ChatMessage, ResolvedModelSelection, Settings, Task, TaskDetail } from "@fusion/core";
import { isWipColumnRole } from "../utils/columnRoles";
import { getErrorMessage } from "@fusion/core";
import { getErrorMessage, isExperimentalFeatureEnabled, CHAT_FOCUS_FLAG } from "@fusion/core";
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Loader2, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -9,7 +9,7 @@ import { useComposerDictation } from "../hooks/useComposerDictation";
import { getPersistedPendingChatMessages, setPersistedPendingChatMessages } from "../hooks/chatPendingMessageStorage";
import { MicButton } from "./MicButton";
import type { ChatMessageInfo, ToolCallInfo } from "../hooks/chatTypes";
import { attachChatStream, cancelChatResponse, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, updateChatSession, type ChatFailureInfo, type ChatStreamErrorMeta } from "../api";
import { attachChatStream, cancelChatResponse, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchSettings, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, updateChatSession, type ChatFailureInfo, type ChatStreamErrorMeta } from "../api";
import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
import { ChatQuestionResponse } from "./ChatQuestionResponse";
import { PendingChatMessageQueue } from "./PendingChatMessageQueue";
@@ -18,7 +18,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl";
import { useModelsCache } from "../hooks/useModelsCache";
import { StandardChatActionButton, StandardChatMessageItem, StandardStreamingMessage, formatModelTag } from "./StandardChatSurface";
import { CHAT_COMMANDS, filterChatCommands, getSlashTriggerMatch, matchChatCommand, type ChatCommand } from "./chat-commands";
import { filterChatCommands, getSlashTriggerMatch, matchChatCommand, selectChatCommands, type ChatCommand } from "./chat-commands";
import { useChatMessageLayout } from "../context/ChatMessageLayoutContext";
import {
createChatInputAutosizeController,
@@ -341,6 +341,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
persisted per-conversation focus without a full session refetch.
*/
const [sessionMemoryFocus, setSessionMemoryFocus] = useState<string | null>(null);
const [chatSettings, setChatSettings] = useState<Settings | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [pendingMessages, setPendingMessages] = useState<string[]>([]);
@@ -387,6 +388,23 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
taskChatModelRef.current = taskChatModel;
}, [addToast, onTaskUpdated, taskChatModel]);
useEffect(() => {
let cancelled = false;
setChatSettings(null);
fetchSettings(projectId)
.then((settings) => {
if (!cancelled) setChatSettings(settings);
})
.catch(() => {
if (!cancelled) setChatSettings(null);
});
return () => {
cancelled = true;
};
}, [projectId]);
const chatFocusEnabled = isExperimentalFeatureEnabled(chatSettings ?? undefined, CHAT_FOCUS_FLAG);
const selectedChatCommands = useMemo(() => selectChatCommands({ chatFocusEnabled }), [chatFocusEnabled]);
const [sessionModel, setSessionModel] = useState<ResolvedModelSelection & { thinkingLevel?: string }>(taskChatModel);
const hasLocalTargetOverrideRef = useRef(false);
const { models, favoriteProviders, favoriteModels } = useModelsCache();
@@ -523,7 +541,10 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
planner edits could land against a task already being implemented.
*/
const agentRunning = isWipColumnRole(columnFlags, task.column);
const filteredCommands = useMemo(() => filterChatCommands(commandFilter, CHAT_COMMANDS), [commandFilter]);
const filteredCommands = useMemo(
() => filterChatCommands(commandFilter, selectedChatCommands),
[commandFilter, selectedChatCommands],
);
useEffect(() => {
setHighlightedCommandIndex(0);
@@ -1069,7 +1090,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
}, [messages, refreshMessagesForSession, refreshTaskAfterEdit, sessionId, startPlannerStream, t]);
const dispatchSlashCommand = useCallback(async (command: ChatCommand, remainder: string) => {
if (!agentRunning) {
if (command.requiresAgent && !agentRunning) {
// Do not silently fall back to a normal chat message: /steer with no
// running agent is a no-op with feedback, not a plain send.
addToastRef.current(t("taskDetail.plannerChat.commandNoRunningAgent", "No running agent to steer"), "warning");
@@ -1128,13 +1149,13 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
const sendMessage = useCallback(() => {
const trimmed = draft.trim();
const commandMatch = matchChatCommand(trimmed, CHAT_COMMANDS);
const commandMatch = matchChatCommand(trimmed, selectedChatCommands);
if (commandMatch) {
setShowCommandMenu(false);
return dispatchSlashCommand(commandMatch.command, commandMatch.remainder);
}
return sendMessageContent(draft);
}, [draft, dispatchSlashCommand, sendMessageContent]);
}, [draft, dispatchSlashCommand, selectedChatCommands, sendMessageContent]);
const handleDraftChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
const nextValue = event.target.value;
@@ -1622,15 +1643,22 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
)}
</div>
)}
<div className="task-planner-chat-focus-row">
<ChatFocusSelector
sessionId={sessionId}
projectId={projectId}
memoryFocus={sessionMemoryFocus}
onPersist={(focus) => setSessionMemoryFocus(focus)}
addToast={(message, type) => addToastRef.current(message, type)}
/>
</div>
{/*
FNXC:ChatMemoryFocus 2026-08-24-04:21:
Suppress the focus chip and its padded wrapper together until experimentalFeatures.chatFocus
is enabled, so default-off planner chat leaves no empty composer shell.
*/}
{chatFocusEnabled && (
<div className="task-planner-chat-focus-row">
<ChatFocusSelector
sessionId={sessionId}
projectId={projectId}
memoryFocus={sessionMemoryFocus}
onPersist={(focus) => setSessionMemoryFocus(focus)}
addToast={(message, type) => addToastRef.current(message, type)}
/>
</div>
)}
<div className="task-planner-chat-composer">
<div className="task-planner-chat-target-controls" data-testid="task-planner-chat-target-controls">
<CustomModelDropdown

View File

@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import React from "react";
import { fireEvent, screen } from "@testing-library/react";
import { ChatView } from "../ChatView";
import * as api from "../../api";
import {
activeSessionFixture,
defaultChatState,
installChatViewEnv,
mockViewportMode,
renderChatDetailWithAct,
setupMockChat,
setupMockRooms,
} from "./ChatView.test-harness";
vi.mock("../../hooks/useChat");
vi.mock("../../hooks/useChatRooms");
vi.mock("../../hooks/useChatUnread", () => ({
useChatUnread: () => ({ isUnread: () => false, markRead: vi.fn() }),
}));
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => ({
...(await importOriginal<typeof import("../../hooks/useNavigationHistory")>()),
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
}));
vi.mock("../CustomModelDropdown", () => ({ CustomModelDropdown: () => null }));
vi.mock("../ChatFocusSelector", () => ({
ChatFocusSelector: () => <button type="button" data-testid="chat-focus-chip" aria-label="Memory focus topic" />,
}));
vi.mock("../../api", () => ({
fetchSettings: vi.fn(),
fetchChatSession: vi.fn().mockResolvedValue({ session: { memoryFocus: null } }),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
fetchTasks: vi.fn().mockResolvedValue([]),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
updateChatSession: vi.fn().mockResolvedValue({}),
}));
installChatViewEnv();
const mockFetchSettings = vi.mocked(api.fetchSettings);
const mockUpdateChatSession = vi.mocked(api.updateChatSession);
const commandContext = { taskId: "FN-9209", projectId: "proj-123", agentRunning: true };
async function renderFocusedChat() {
const session = { ...activeSessionFixture, id: "focus-session" };
setupMockChat({
...defaultChatState,
activeSession: session,
sessions: [session],
filteredSessions: [session],
});
setupMockRooms();
await renderChatDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} chatCommandContext={commandContext} />);
}
describe("ChatView chat focus experimental flag", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchSettings.mockResolvedValue({} as Awaited<ReturnType<typeof api.fetchSettings>>);
vi.mocked(api.fetchChatSession).mockResolvedValue({ session: { memoryFocus: null } } as Awaited<ReturnType<typeof api.fetchChatSession>>);
localStorage.setItem("fusion:chat-scope", "direct");
mockViewportMode("desktop");
});
it("defaults off, including for a session with persisted focus", async () => {
vi.mocked(api.fetchChatSession).mockResolvedValue({ session: { memoryFocus: "auth-northstar" } } as Awaited<ReturnType<typeof api.fetchChatSession>>);
await renderFocusedChat();
expect(screen.queryByTestId("chat-focus-chip")).toBeNull();
expect(screen.queryByRole("button", { name: "Memory focus topic" })).toBeNull();
});
it("renders the chip only after explicit opt-in", async () => {
mockFetchSettings.mockResolvedValue({ experimentalFeatures: { chatFocus: true } } as Awaited<ReturnType<typeof api.fetchSettings>>);
await renderFocusedChat();
expect(await screen.findByTestId("chat-focus-chip")).toBeInTheDocument();
});
it("fails closed when the settings request rejects", async () => {
mockFetchSettings.mockRejectedValue(new Error("settings unavailable"));
await renderFocusedChat();
await Promise.resolve();
expect(screen.queryByTestId("chat-focus-chip")).toBeNull();
});
it("withholds and refuses /focus while preserving /steer when off", async () => {
await renderFocusedChat();
const input = screen.getByTestId("chat-input");
fireEvent.change(input, { target: { value: "/" } });
expect(await screen.findByText("/steer")).toBeInTheDocument();
expect(screen.queryByText("/focus")).toBeNull();
fireEvent.change(input, { target: { value: "/focus auth-northstar" } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
expect(mockUpdateChatSession).not.toHaveBeenCalled();
});
it("restores /focus menu and submit dispatch after opt-in", async () => {
mockFetchSettings.mockResolvedValue({ experimentalFeatures: { chatFocus: true } } as Awaited<ReturnType<typeof api.fetchSettings>>);
await renderFocusedChat();
const input = screen.getByTestId("chat-input");
fireEvent.change(input, { target: { value: "/" } });
expect(await screen.findByText("/focus")).toBeInTheDocument();
fireEvent.change(input, { target: { value: "/focus auth-northstar" } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
expect(mockUpdateChatSession).toHaveBeenCalledWith("focus-session", { memoryFocus: "auth-northstar" }, "proj-123");
});
it("keeps the focus chip out of the narrow mobile composer", async () => {
mockViewportMode("mobile");
await renderFocusedChat();
expect(screen.queryByTestId("chat-focus-chip")).toBeNull();
expect(document.querySelector(".chat-input-row [data-testid='chat-focus-chip']")).toBeNull();
});
});

View File

@@ -27,7 +27,7 @@ vi.mock("lucide-react", async (importOriginal) => ({
Target: (props: React.SVGProps<SVGSVGElement>) => React.createElement("svg", props),
}));
vi.mock("../../api", () => ({
fetchSettings: vi.fn().mockResolvedValue({}),
fetchSettings: vi.fn().mockResolvedValue({ experimentalFeatures: { chatFocus: true } }),
fetchChatSession: vi.fn().mockResolvedValue({ session: { memoryFocus: null } }),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
fetchAgents: vi.fn().mockResolvedValue([]),

View File

@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
import * as api from "../../api";
const mocks = vi.hoisted(() => ({
fetchSettings: vi.fn(),
fetchTaskPlannerChatSession: vi.fn(),
fetchChatSession: vi.fn(),
fetchChatMessages: vi.fn(),
ensureTaskPlannerChatSession: vi.fn(),
fetchTaskDetail: vi.fn(),
updateChatSession: vi.fn(),
streamChatResponse: vi.fn(),
attachChatStream: vi.fn(),
cancelChatResponse: vi.fn(),
addSteeringComment: vi.fn(),
}));
vi.mock("../../api", async (importOriginal) => ({
...(await importOriginal<typeof import("../../api")>()),
...mocks,
}));
vi.mock("../../hooks/useModelsCache", () => ({
useModelsCache: () => ({ models: [], favoriteProviders: [], favoriteModels: [] }),
}));
vi.mock("../CustomModelDropdown", () => ({ CustomModelDropdown: () => null }));
vi.mock("../ChatFocusSelector", () => ({
ChatFocusSelector: () => <button type="button" data-testid="chat-focus-root" aria-label="Memory focus topic" />,
}));
const task = {
id: "FN-9209",
description: "Flag chat focus",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
createdAt: "2026-08-24T00:00:00.000Z",
updatedAt: "2026-08-24T00:00:00.000Z",
};
const session = {
id: "planner-session",
agentId: "task-planner:FN-9209",
title: "Planner",
status: "active",
createdAt: "2026-08-24T00:00:00.000Z",
updatedAt: "2026-08-24T00:00:00.000Z",
memoryFocus: "auth-northstar",
};
function renderPlanner() {
return render(
<TaskPlannerChatTab
task={task as never}
active
projectId="proj-123"
taskChatModel={{ provider: "anthropic", modelId: "claude" }}
addToast={vi.fn()}
/>,
);
}
describe("TaskPlannerChatTab chat focus experimental flag", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.fetchSettings.mockResolvedValue({});
mocks.fetchTaskPlannerChatSession.mockResolvedValue({ session });
mocks.fetchChatSession.mockResolvedValue({ session });
mocks.fetchChatMessages.mockResolvedValue({ messages: [] });
mocks.ensureTaskPlannerChatSession.mockResolvedValue({ session });
mocks.fetchTaskDetail.mockResolvedValue(task);
mocks.updateChatSession.mockResolvedValue({ session });
mocks.streamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mocks.attachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mocks.cancelChatResponse.mockResolvedValue({ success: true, interrupted: false });
mocks.addSteeringComment.mockResolvedValue(task);
});
it("removes both the chip and its padded wrapper by default", async () => {
const { container } = renderPlanner();
await screen.findByLabelText("Message task chat");
expect(screen.queryByTestId("chat-focus-root")).toBeNull();
expect(container.querySelector(".task-planner-chat-focus-row")).toBeNull();
});
it("does not list /focus in the planner composer while off", async () => {
renderPlanner();
const input = await screen.findByLabelText("Message task chat");
fireEvent.change(input, { target: { value: "/" } });
expect(await screen.findByText("/steer")).toBeInTheDocument();
expect(screen.queryByText("/focus")).toBeNull();
});
it("restores the wrapper, chip, and command after explicit opt-in", async () => {
mocks.fetchSettings.mockResolvedValue({ experimentalFeatures: { chatFocus: true } });
const { container } = renderPlanner();
const input = await screen.findByLabelText("Message task chat");
expect(await screen.findByTestId("chat-focus-root")).toBeInTheDocument();
expect(container.querySelector(".task-planner-chat-focus-row")).not.toBeNull();
fireEvent.change(input, { target: { value: "/" } });
expect(await screen.findByText("/focus")).toBeInTheDocument();
});
});

View File

@@ -20,13 +20,14 @@ const mockModelCatalog = vi.hoisted(() => ({
],
}));
const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockUpdateChatSession, mockStreamChatResponse, mockAttachChatStream, mockCancelChatResponse, mockAddSteeringComment, mockTranslations, mockT } = vi.hoisted(() => {
const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatSession, mockFetchChatMessages, mockFetchSettings, mockFetchTaskDetail, mockUpdateChatSession, mockStreamChatResponse, mockAttachChatStream, mockCancelChatResponse, mockAddSteeringComment, mockTranslations, mockT } = vi.hoisted(() => {
const translations = new Map<string, string>();
return {
mockEnsureTaskPlannerChatSession: vi.fn(),
mockFetchTaskPlannerChatSession: vi.fn(),
mockFetchChatSession: vi.fn(),
mockFetchChatMessages: vi.fn(),
mockFetchSettings: vi.fn().mockResolvedValue({}),
mockFetchTaskDetail: vi.fn(),
mockUpdateChatSession: vi.fn(),
mockStreamChatResponse: vi.fn(),
@@ -64,6 +65,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchTaskPlannerChatSession: mockFetchTaskPlannerChatSession,
fetchChatSession: mockFetchChatSession,
fetchChatMessages: mockFetchChatMessages,
fetchSettings: mockFetchSettings,
fetchTaskDetail: mockFetchTaskDetail,
updateChatSession: mockUpdateChatSession,
streamChatResponse: mockStreamChatResponse,
@@ -218,6 +220,7 @@ describe("TaskPlannerChatTab", () => {
});
it("keeps a cleared planner memory-focus control icon-only with its accessible name", async () => {
mockFetchSettings.mockResolvedValue({ experimentalFeatures: { chatFocus: true } });
const plannerSession = makePlannerSession({ memoryFocus: null });
mockFetchTaskPlannerChatSession.mockResolvedValue({ session: plannerSession });
mockFetchChatSession.mockResolvedValue({ session: plannerSession });

View File

@@ -5,7 +5,7 @@ vi.mock("../../api", () => ({
}));
import { addSteeringComment } from "../../api";
import { CHAT_COMMANDS, matchChatCommand, filterChatCommands, type ChatCommand } from "../chat-commands";
import { CHAT_COMMANDS, matchChatCommand, filterChatCommands, selectChatCommands, type ChatCommand } from "../chat-commands";
const mockAddSteeringComment = vi.mocked(addSteeringComment);
@@ -27,6 +27,11 @@ describe("chat-commands registry", () => {
});
});
it("keeps the registry intact while selecting a flag-aware dispatch list", () => {
expect(selectChatCommands({ chatFocusEnabled: true })).toBe(CHAT_COMMANDS);
expect(selectChatCommands({ chatFocusEnabled: false }).map((command) => command.name)).toEqual(["steer"]);
});
describe("matchChatCommand", () => {
it("extracts the trigger and remainder for '/steer <text>'", () => {
const match = matchChatCommand("/steer do X");

View File

@@ -6,7 +6,7 @@ vi.mock("../../api", () => ({
}));
import { updateChatSession } from "../../api";
import { CHAT_COMMANDS, filterChatCommands, matchChatCommand } from "../chat-commands";
import { CHAT_COMMANDS, filterChatCommands, matchChatCommand, selectChatCommands } from "../chat-commands";
const mockUpdateChatSession = vi.mocked(updateChatSession);
@@ -57,6 +57,17 @@ describe("focus slash command", () => {
expect(filterChatCommands("")).toHaveLength(CHAT_COMMANDS.length);
});
it("withholds /focus from flag-off menus and dispatch while retaining /steer", () => {
const disabled = selectChatCommands({ chatFocusEnabled: false });
expect(disabled.map((command) => command.name)).toContain("steer");
expect(disabled.map((command) => command.name)).not.toContain("focus");
expect(matchChatCommand("/focus topic", disabled)).toBeNull();
const enabled = selectChatCommands({ chatFocusEnabled: true });
expect(enabled).toBe(CHAT_COMMANDS);
expect(matchChatCommand("/focus topic", enabled)?.command.name).toBe("focus");
});
it("persists the topic via updateChatSession with the session id, topic, and project id", async () => {
mockUpdateChatSession.mockResolvedValueOnce({ session: { id: "SES-1", memoryFocus: "auth-northstar" } } as any);
const focus = CHAT_COMMANDS.find((command) => command.name === "focus")!;

View File

@@ -104,6 +104,17 @@ export const CHAT_COMMANDS: readonly ChatCommand[] = [
},
];
/*
FNXC:ChatMemoryFocus 2026-08-24-04:21:
/focus remains registered for persistence compatibility but is withheld from both menu listing and
submit dispatch while experimentalFeatures.chatFocus is off, so flag-off composers cannot invoke it.
*/
export function selectChatCommands(options: { chatFocusEnabled: boolean }): readonly ChatCommand[] {
return options.chatFocusEnabled
? CHAT_COMMANDS
: CHAT_COMMANDS.filter((command) => command.name !== "focus");
}
export interface ChatCommandMatch {
command: ChatCommand;
remainder: string;

View File

@@ -45,15 +45,15 @@ vi.mock("@fusion/core", async (importOriginal) => {
};
});
const baseTaskStore = () => ({
getSettings: vi.fn(async () => ({})),
const baseTaskStore = (settings: Record<string, unknown> = { experimentalFeatures: { chatFocus: true } }) => ({
getSettings: vi.fn(async () => settings),
} as unknown as TaskStore);
const baseAgentStore = {} as unknown as AgentStore;
async function buildToolset(focus: string | undefined) {
async function buildToolset(focus: string | undefined, settings?: Record<string, unknown>) {
return createChatFusionToolset({
taskStore: baseTaskStore(),
taskStore: baseTaskStore(settings),
agentStore: baseAgentStore,
rootDir: "/project",
agentId: "agent-abc",
@@ -80,6 +80,14 @@ describe("chat memory-focus production reachability (RUFU-068)", () => {
expect(memoryCalls.calls[0]).toMatchObject({ query: "recall target", topic: "stash lcm" });
});
it.each([{}, { experimentalFeatures: { chatFocus: false } }])("keeps persisted focus inert while the flag is off", async (settings) => {
const tools = await buildToolset("stash lcm", settings);
const tool = tools.find((candidate) => candidate.name === "fn_memory_search")!;
await (tool.execute as (id: string, params: Record<string, unknown>) => Promise<unknown>)("1", { query: "recall target", projectDir: "/project" });
expect(memoryCalls.calls[0]).not.toHaveProperty("topic");
});
it("leaves recall whole-project when the session has no focus (undefined -> no topic)", async () => {
const tools = await buildToolset(undefined);
const searchTool = tools.find((t) => t.name === "fn_memory_search");

View File

@@ -40,6 +40,8 @@ import {
createLogger,
resolvePermanentAgentEffectiveModel,
resolvePermanentAgentEffectiveThinkingLevel,
isExperimentalFeatureEnabled,
CHAT_FOCUS_FLAG,
} from "@fusion/core";
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
@@ -472,14 +474,10 @@ export interface ChatFusionToolsetOptions {
/** Required for command-execution requests; status remains safely readable without it. */
actionGateContext?: AgentActionGateContext;
/*
FNXC:ChatMemoryFocus 2026-08-13:
Per-conversation memory focus (RUFU-068). When the enclosing chat session carries an active
topic (chat_sessions.memory_focus, set via /focus or the per-chat selector), thread it into
createMemoryTools so fn_memory_search scopes project recall to that topic. This is a
WITHIN-project read filter only: the topic reaches backend.search (the SQL enforcement point)
and is never a client-side post-query filter. undefined/'all'/empty/'*' → whole-project scope.
Rooms have no per-room focus field yet, so room-responder tool sites pass undefined and recall
stays whole-project; only direct chat (sendMessage) carries session.memoryFocus today.
FNXC:ChatMemoryFocus 2026-08-24-04:21:
Per-conversation memory focus storage remains available, but every reader is gated by
experimentalFeatures.chatFocus. A persisted topic is inert and recall stays whole-project
until operators opt in; enabled sessions still scope fn_memory_search at the backend.
*/
focus?: string;
}
@@ -683,7 +681,11 @@ export async function createChatFusionToolset(options: ChatFusionToolsetOptions)
...createIdeationTools(taskStore).filter((tool) => missionMutationGated || CHAT_IDEATION_READ_TOOL_NAMES.has(tool.name)),
...createGoalRetrievalTools(taskStore),
/* FNXC:ChatAgentTools 2026-07-15-00:00: Chat exposes memory retrieval only and respects the workspace memory-enabled setting; prompt-triggered persistent writes stay excluded without an action-gate context. */
...createMemoryTools(rootDir, settings, focus ? { focus } : undefined).filter((tool) => tool.name !== "fn_memory_append"),
...createMemoryTools(
rootDir,
settings,
focus && isExperimentalFeatureEnabled(settings, CHAT_FOCUS_FLAG) ? { focus } : undefined,
).filter((tool) => tool.name !== "fn_memory_append"),
...createResearchTools({ store: taskStore, rootDir, getSettings: () => taskStore.getSettings() }),
);
}
@@ -2939,12 +2941,10 @@ export class ChatManager {
missionMutationGated: missionGateContexts.missionMutationGated,
actionGateContext: missionGateContexts.actionGateContext,
/*
FNXC:ChatMemoryFocus 2026-08-13:
Direct-chat recall scopes fn_memory_search to the session's persisted topic
(chat_sessions.memory_focus). This is the production path that makes the /focus
command's persisted value actually reach the tool's search options — without it the
operator would see the chip but receive whole-project recall. Rooms have no focus
field yet, so the room-responder toolset passes undefined (whole-project scope).
FNXC:ChatMemoryFocus 2026-08-24-04:21:
Direct-chat sessions retain their persisted topic for storage compatibility, but the
toolset applies it only while experimentalFeatures.chatFocus is enabled. Otherwise the
value is inert and both direct and room chat recall remain whole-project.
*/
focus: session?.memoryFocus ?? undefined,
});