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:
7
.changeset/fn-9209-chat-focus-experimental-flag.md
Normal file
7
.changeset/fn-9209-chat-focus-experimental-flag.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")!;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user