Merge remote-tracking branch 'origin/main'

# Conflicts:
#	packages/core/src/__tests__/settings-defaults.test.ts
#	packages/dashboard/app/components/ChatView.tsx
This commit is contained in:
Fusion Agent
2026-08-27 02:50:06 +00:00
37 changed files with 1055 additions and 227 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep Chat memory Focus popovers usable on mobile and narrow chat surfaces.
category: fix
dev: Re-anchor the popover to each composer row and bound its scrollable height.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Chat memory-focus button is icon-only until a topic is set.
category: feature
dev: ChatFocusSelector no longer renders the cleared chat.focusNone label.

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

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Preserve dirty or unverifiable worktrees during automatic cleanup.
category: fix
dev: Automatic cleanup now fails closed for unverified content and revalidates cleanliness without force at removal time.

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

@@ -212,7 +212,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. |
@@ -1705,6 +1705,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, isGlobalSettingsKey, isProjectSettingsKey } from "../config/settings-schema.js";
import {
__resetLegacyCwdMainWarningForTests,
@@ -78,6 +78,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

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

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

@@ -1564,6 +1564,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

@@ -1,29 +1,22 @@
/*
FNXC:ChatMemoryFocusSelector 2026-08-13:
Styling for the per-conversation memory focus selector chip + popover. Uses only
design tokens (--space-*, --color/--surface/--border/--text-muted/--accent,
--radius-*, --shadow-lg, --font-size-*, --font-weight-*) per the styling guide —
no hardcoded px (except 0), no hex/rgba. A null/empty session focus renders a
cleared "Focus" chip (never a dangling topic chip); a set topic renders an active
chip whose label is the topic. Mobile (max-width 768px) widens the popover to the
viewport minus padding so the inline input stays usable on narrow screens. The
chip matches --chat-input-control-size (the composer's send/attach control
height) so it centers with the single-line textarea.
FNXC:ChatMemoryFocusSelector 2026-08-24-03:40:
The focus popover must use a positioned, full-width composer ancestor as its containing
block, never the chip wrapper. A chip-anchored mobile breakpoint resolved its insets
against the trigger and collapsed the popover into a vertical sliver. Every future
ChatFocusSelector host must provide that positioned composer ancestor. The bounded,
scrollable box keeps its title and controls available when the chat pane clips overflow.
FNXC:ChatMemoryFocusSelector 2026-08-21-13:35:
RUFU-146 review (PRRT_kwDOSA-8Y86a7RZo): raw literals replaced with existing
semantic tokens — --btn-border-width (borders), --transition-fast
(chip transition), --opacity-disabled (disabled chip), --focus-ring-strong
(focus-visible, previously an invalid `outline: 2px solid var(--focus-ring)`
declaration since --focus-ring is a box-shadow token), calc(var(--space-xl) * 8)
(12rem label cap), --z-popover (popover layer), --line-height-normal (help text).
The 768px breakpoint stays literal by design: --mobile-breakpoint is documented
in styles.css as documentation-only (custom properties cannot appear in
@media conditions) and every other component CSS uses the same literal.
The chip matches --chat-input-control-size (the composer's send/attach control height)
so it centers with the single-line textarea. Styling uses only existing design tokens.
FNXC:ChatMemoryFocusSelector 2026-08-24-03:59:
A cleared focus is an icon-only control: its aria-label and title supply the accessible
name, while a selected topic retains its visible chip label. The modifier removes label
padding and gap so the cleared control stays square at every breakpoint.
*/
.chat-focus-root {
position: relative;
position: static;
flex: none;
}
@@ -57,8 +50,14 @@ in styles.css as documentation-only (custom properties cannot appear in
border-color: var(--accent);
}
.chat-focus-chip-topic,
.chat-focus-chip-label {
.chat-focus-chip--icon-only {
inline-size: var(--chat-input-control-size, 2.25rem);
padding: 0;
gap: 0;
justify-content: center;
}
.chat-focus-chip-topic {
max-inline-size: calc(var(--space-xl) * 8);
overflow: hidden;
text-overflow: ellipsis;
@@ -68,11 +67,13 @@ in styles.css as documentation-only (custom properties cannot appear in
.chat-focus-popover {
position: absolute;
left: 0;
bottom: calc(100% + var(--space-xs));
width: min(calc(var(--space-xl) * 15), calc(100vw - (var(--space-lg) * 2)));
max-width: calc(100vw - (var(--space-lg) * 2));
max-inline-size: calc(100vw - (var(--space-lg) * 2));
inset-inline-start: var(--space-md);
inset-inline-end: auto;
inset-block-end: calc(100% + var(--space-xs));
inline-size: min(calc(var(--space-xl) * 15), calc(100% - (var(--space-md) * 2)));
max-inline-size: calc(100% - (var(--space-md) * 2));
max-block-size: min(calc(var(--space-xl) * 16), calc(100vh - (var(--space-xl) * 6)));
overflow-y: auto;
padding: var(--space-sm);
background: var(--surface);
border: var(--btn-border-width) solid var(--border);
@@ -102,6 +103,7 @@ in styles.css as documentation-only (custom properties cannot appear in
.chat-focus-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
@@ -109,13 +111,3 @@ in styles.css as documentation-only (custom properties cannot appear in
.chat-focus-clear {
flex: 0 0 auto;
}
@media (max-width: 768px) {
.chat-focus-popover {
left: var(--space-md);
right: var(--space-md);
width: auto;
max-width: none;
max-inline-size: none;
}
}

View File

@@ -13,9 +13,14 @@ empty -> null and bumps updatedAt) so it survives reconnect. Recall is then
scoped to that topic as a WITHIN-project read filter
(searchProjectMemory -> backend.search -> Stash REST topic param) NEVER a
client-side / post-query in-memory filter, and cross-project A/B isolation is
never weakened. A null/absent focus shows a cleared state (a "focus" chip to
set one), never a dangling chip, and an empty value or "all"/"*" collapses to
whole-project scope. Capture stays write-anywhere and topic-agnostic.
never weakened. A null/absent focus shows an icon-only chip; its aria-label and title preserve an
accessible name without consuming composer width. A set topic remains visible on the
chip, and an empty value or "all"/"*" collapses to whole-project scope. Capture stays
write-anywhere and topic-agnostic.
FNXC:ChatMemoryFocusSelector 2026-08-24-03:59:
The cleared state must not render the redundant "Focus" word. It uses the button's
aria-label and title as its accessible name while a selected topic remains visible.
*/
export interface ChatFocusSelectorProps {
@@ -133,7 +138,7 @@ export function ChatFocusSelector({
<div className="chat-focus-root" ref={rootRef} data-testid="chat-focus-root">
<button
type="button"
className={`chat-focus-chip${hasTopic ? " chat-focus-chip--active" : ""}`}
className={`chat-focus-chip${hasTopic ? " chat-focus-chip--active" : " chat-focus-chip--icon-only"}`}
data-testid="chat-focus-chip"
aria-haspopup="dialog"
aria-expanded={open}
@@ -146,11 +151,7 @@ export function ChatFocusSelector({
}}
>
<Target size={14} aria-hidden="true" />
{hasTopic ? (
<span className="chat-focus-chip-topic">{focusedTopic}</span>
) : (
<span className="chat-focus-chip-label">{t("chat.focusNone", "Focus")}</span>
)}
{hasTopic ? <span className="chat-focus-chip-topic">{focusedTopic}</span> : null}
</button>
{open && sessionId ? (

View File

@@ -32,7 +32,7 @@ import { useChatUnread } from "../hooks/useChatUnread";
import { useComposerDictation } from "../hooks/useComposerDictation";
import { useViewportMode } from "./Header";
import { fetchSettings, fetchChatSession, 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 { MicButton } from "./MicButton";
import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl";
import { ChatThreadTitleSwitcher } from "./ChatThreadTitleSwitcher";
@@ -66,7 +66,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,
@@ -408,6 +408,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:
@@ -815,8 +817,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) => ({
@@ -1657,7 +1659,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
@@ -1786,6 +1788,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
chatCommandContext,
isStreaming,
releaseSentAttachments,
selectedChatCommands,
t,
]);
@@ -2902,19 +2905,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. The direct composer owns
this per-conversation control.
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-08-24-03:34:
Direct sessions retain model/agent targeting here. CLI-backed sessions broker to a live PTY and never receive

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

@@ -236,12 +236,14 @@ Task Chat keeps model and thinking controls reachable beside the composer, reusi
}
/*
FNXC:ChatMemoryFocus 2026-08-13:
Spacing row holding the per-conversation memory focus chip above the planner
composer. The chip inherits its control-size token from the composer below so
it aligns with the send/stop buttons.
FNXC:ChatMemoryFocusSelector 2026-08-24-03:40:
The planner focus popover must be contained by this full-width composer row, never the
chip or the full chat pane. The chip-anchored mobile override collapsed the popover into
a sliver; a future ChatFocusSelector host must likewise provide a positioned full-width
composer ancestor. The chip inherits its control-size token from the composer below.
*/
.task-planner-chat-focus-row {
position: relative;
display: flex;
flex: 0 0 auto;
align-items: center;

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

@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from "vitest";
import React from "react";
import { screen } from "@testing-library/react";
import { ChatView } from "../ChatView";
import {
activeSessionFixture,
defaultChatState,
installChatViewEnv,
renderChatDetailWithAct,
setupMockChat,
setupMockRooms,
} from "./ChatView.test-harness";
// Factories stay inline: importing the shared harness from a factory creates a TDZ cycle.
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("lucide-react", async (importOriginal) => ({
...(await importOriginal<typeof import("lucide-react")>()),
Target: (props: React.SVGProps<SVGSVGElement>) => React.createElement("svg", props),
}));
vi.mock("../../api", () => ({
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([]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
fetchTasks: vi.fn().mockResolvedValue([]),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
updateChatSession: vi.fn(),
}));
installChatViewEnv();
describe("ChatView memory focus chip", () => {
it("keeps a cleared direct-chat focus control icon-only with its accessible name", async () => {
const session = { ...activeSessionFixture, id: "session-focus", title: "Focus-free chat" };
setupMockChat({
...defaultChatState,
activeSession: session,
sessions: [session],
filteredSessions: [session],
});
setupMockRooms();
await renderChatDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const chip = screen.getByRole("button", { name: "Memory focus topic" });
expect(chip.textContent?.trim()).toBe("");
expect(chip).not.toHaveTextContent(/Focus/);
});
});

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,
@@ -217,6 +219,19 @@ describe("TaskPlannerChatTab", () => {
restoreMetricDescriptor("clientHeight", originalClientHeightDescriptor);
});
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 });
renderPlannerChat();
const chip = await screen.findByRole("button", { name: "Memory focus topic" });
expect(chip.closest(".task-planner-chat-focus-row")).toBeTruthy();
expect(chip.textContent?.trim()).toBe("");
expect(chip).not.toHaveTextContent(/Focus/);
});
it("looks up an existing task-scoped planner session and renders the starter-prompt empty state", async () => {
renderPlannerChat();

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

@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ChatFocusSelector } from "../ChatFocusSelector";
import { loadAllAppCss } from "../../test/cssFixture";
vi.mock("../../api", () => ({
updateChatSession: vi.fn(),
}));
import { updateChatSession } from "../../api";
/*
FNXC:ChatMemoryFocusSelector 2026-08-24-03:40:
The focus popover must use its full-width composer row as its containing block. These
rendered host chains prevent a chip-sized positioned wrapper from collapsing the mobile
popover into a vertical sliver.
*/
const appCss = loadAllAppCss();
const mockUpdateChatSession = vi.mocked(updateChatSession);
const focusStates = [null, "", "all", "*", "a deliberately long memory focus topic for the narrow two-button state"];
afterEach(() => {
cleanup();
document.head.querySelector("style[data-chat-focus-css]")?.remove();
});
beforeEach(() => {
mockUpdateChatSession.mockReset();
});
function renderWithCss(ui: JSX.Element) {
const style = document.createElement("style");
style.dataset.chatFocusCss = "true";
style.textContent = appCss;
document.head.appendChild(style);
return render(ui);
}
function nearestPositionedAncestor(element: HTMLElement): HTMLElement | null {
let current = element.parentElement;
while (current) {
if (getComputedStyle(current).position !== "static") return current;
current = current.parentElement;
}
return null;
}
function FocusSelector({ memoryFocus = null }: { memoryFocus?: string | null }) {
return (
<ChatFocusSelector
sessionId="SES-1"
memoryFocus={memoryFocus}
onPersist={() => undefined}
addToast={() => undefined}
/>
);
}
function openPopover() {
fireEvent.click(screen.getByTestId("chat-focus-chip"));
return screen.getByTestId("chat-focus-popover");
}
function expectPopoverGeometry(anchorClass: string) {
const popover = openPopover();
const root = screen.getByTestId("chat-focus-root");
const positionedAncestor = nearestPositionedAncestor(popover);
const popoverStyle = getComputedStyle(popover);
expect(positionedAncestor).toHaveClass(anchorClass);
expect(positionedAncestor).not.toBe(root);
expect(positionedAncestor).not.toHaveClass("task-planner-chat");
expect(getComputedStyle(root).position).toBe("static");
expect(popoverStyle.maxBlockSize || popoverStyle.maxHeight).not.toBe("");
expect(popoverStyle.overflowY).toBe("auto");
}
function renderChatHost({ narrow, memoryFocus }: { narrow: boolean; memoryFocus: string | null }) {
return renderWithCss(
<div className={`chat-view${narrow ? " chat-view--narrow" : ""}`}>
<div className="chat-thread">
<div className="chat-input-area">
<div className="chat-input-row"><FocusSelector memoryFocus={memoryFocus} /></div>
</div>
</div>
</div>,
);
}
function renderPlannerHost(memoryFocus: string | null) {
return renderWithCss(
<div className="task-planner-chat">
<div className="task-planner-chat-focus-row"><FocusSelector memoryFocus={memoryFocus} /></div>
<div className="task-planner-chat-composer" />
</div>,
);
}
describe("ChatFocusSelector narrow host geometry", () => {
it.each([false, true])("anchors ChatView focus popovers to the composer area when narrow=%s", (narrow) => {
renderChatHost({ narrow, memoryFocus: null });
expectPopoverGeometry("chat-input-area");
});
it("anchors the planner focus popover to its composer row instead of the pane", () => {
renderPlannerHost(null);
expectPopoverGeometry("task-planner-chat-focus-row");
});
it.each(focusStates)("keeps the bounded ChatView popover usable for memoryFocus=%j", (memoryFocus) => {
renderChatHost({ narrow: true, memoryFocus });
expectPopoverGeometry("chat-input-area");
if (memoryFocus && memoryFocus !== "all" && memoryFocus !== "*") {
expect(screen.getByTestId("chat-focus-save")).toBeInTheDocument();
expect(screen.getByTestId("chat-focus-clear")).toBeInTheDocument();
expect(getComputedStyle(screen.getByTestId("chat-focus-save").parentElement as HTMLElement).flexWrap).toBe("wrap");
} else {
expect(screen.getByTestId("chat-focus-save")).toBeInTheDocument();
expect(screen.queryByTestId("chat-focus-clear")).not.toBeInTheDocument();
}
});
it("keeps the bounded planner popover usable for every focus state", () => {
for (const memoryFocus of focusStates) {
renderPlannerHost(memoryFocus);
expectPopoverGeometry("task-planner-chat-focus-row");
cleanup();
}
});
it("dismisses the popover by keyboard and pointer without changing its host geometry", () => {
renderChatHost({ narrow: true, memoryFocus: null });
expectPopoverGeometry("chat-input-area");
fireEvent.keyDown(screen.getByTestId("chat-focus-input"), { key: "Escape" });
expect(screen.queryByTestId("chat-focus-popover")).not.toBeInTheDocument();
openPopover();
fireEvent.pointerDown(document.body);
expect(screen.queryByTestId("chat-focus-popover")).not.toBeInTheDocument();
});
it("disables both actions while a focus update is saving", () => {
mockUpdateChatSession.mockReturnValueOnce(new Promise(() => undefined));
renderChatHost({ narrow: true, memoryFocus: "active topic" });
openPopover();
fireEvent.click(screen.getByTestId("chat-focus-save"));
expect(screen.getByTestId("chat-focus-save")).toBeDisabled();
expect(screen.getByTestId("chat-focus-clear")).toBeDisabled();
});
it("does not render a popover for a missing session", () => {
renderWithCss(
<div className="chat-input-area">
<div className="chat-input-row">
<ChatFocusSelector sessionId={null} memoryFocus={null} onPersist={() => undefined} addToast={() => undefined} />
</div>
</div>,
);
expect(screen.getByTestId("chat-focus-chip")).toBeDisabled();
expect(screen.queryByTestId("chat-focus-popover")).not.toBeInTheDocument();
});
});

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { ChatFocusSelector } from "../ChatFocusSelector";
import { loadAllAppCss } from "../../test/cssFixture";
vi.mock("../../api", () => ({
updateChatSession: vi.fn(),
@@ -10,6 +11,7 @@ vi.mock("../../api", () => ({
import { updateChatSession } from "../../api";
const mockUpdateChatSession = vi.mocked(updateChatSession);
const originalInnerWidthDescriptor = Object.getOwnPropertyDescriptor(window, "innerWidth");
/*
FNXC:ChatMemoryFocusSelectorTest 2026-08-13:
@@ -43,31 +45,57 @@ describe("ChatFocusSelector", () => {
addToast.mockReset();
});
afterEach(() => {
document.head.querySelector("[data-testid='chat-focus-selector-css']")?.remove();
if (originalInnerWidthDescriptor) {
Object.defineProperty(window, "innerWidth", originalInnerWidthDescriptor);
}
});
it("shows the active topic on the chip when the session has a focus", () => {
renderSelector({ memoryFocus: "auth-northstar" });
expect(screen.getByTestId("chat-focus-chip")).toHaveTextContent("auth-northstar");
});
it("shows a cleared/absent state when the session focus is null (no dangling topic chip)", () => {
renderSelector({ memoryFocus: null });
const chip = screen.getByTestId("chat-focus-chip");
expect(chip).toHaveTextContent("Focus");
expect(chip).not.toHaveTextContent("auth-northstar");
it.each([null, "", "all", "*"])("renders memoryFocus=%j as an icon-only whole-project control", (memoryFocus) => {
renderSelector({ memoryFocus });
const chip = screen.getByRole("button", { name: "Memory focus topic" });
expect(chip.textContent?.trim()).toBe("");
expect(chip).not.toHaveTextContent(/Focus/);
expect(chip.querySelector("svg")).toBeTruthy();
expect(chip).toHaveClass("chat-focus-chip--icon-only");
});
it("treats an empty-string and whole-project-collapse focus as cleared", () => {
renderSelector({ memoryFocus: "" });
expect(screen.getByTestId("chat-focus-chip")).toHaveTextContent("Focus");
// "all" and "*" collapse to whole-project scope on display.
const { unmount } = renderSelector({ memoryFocus: "all" });
unmount();
void renderSelector({ memoryFocus: "*" });
expect(screen.getAllByTestId("chat-focus-chip")[0]).toHaveTextContent("Focus");
it("keeps the disabled no-session control icon-only and accessible", () => {
renderSelector({ sessionId: null });
const chip = screen.getByRole("button", { name: "Memory focus topic" });
expect(chip).toBeDisabled();
expect(chip.textContent?.trim()).toBe("");
expect(chip).not.toHaveTextContent(/Focus/);
});
it("preserves a set topic verbatim (whitespace-trimmed active chip)", () => {
renderSelector({ memoryFocus: " spaced topic " });
expect(screen.getByTestId("chat-focus-chip")).toHaveTextContent("spaced topic");
const chip = screen.getByTestId("chat-focus-chip");
expect(chip).toHaveTextContent("spaced topic");
expect(chip).toHaveClass("chat-focus-chip--active");
});
it.each(["desktop", "mobile"])("keeps the icon-only chip square without label spacing at the %s cascade", (viewport) => {
Object.defineProperty(window, "innerWidth", { value: viewport === "mobile" ? 768 : 1024, configurable: true });
const style = document.createElement("style");
style.dataset.testid = "chat-focus-selector-css";
style.textContent = loadAllAppCss();
document.head.appendChild(style);
renderSelector({ memoryFocus: null });
const computed = getComputedStyle(screen.getByTestId("chat-focus-chip"));
expect(computed.paddingLeft).toBe("0px");
expect(computed.paddingRight).toBe("0px");
expect(computed.gap).toBe("0px");
expect(computed.inlineSize).toContain("var(--chat-input-control-size");
});
it("persists a typed topic via updateChatSession and reflects the persisted state", async () => {
@@ -107,8 +135,4 @@ describe("ChatFocusSelector", () => {
await waitFor(() => expect(mockUpdateChatSession).toHaveBeenCalledWith("SES-1", { memoryFocus: null }, "proj-123"));
});
it("is hidden/disabled when there is no session id", () => {
renderSelector({ sessionId: null });
expect(screen.getByTestId("chat-focus-chip")).toBeDisabled();
});
})

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";
@@ -510,14 +512,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;
}
@@ -721,7 +719,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() }),
);
}
@@ -3131,12 +3133,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,
});

View File

@@ -0,0 +1,153 @@
import { access, constants as fsConstants, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
RemovalReason,
removeWorktree,
} from "../../worktree/worktree-backend.js";
import { reapOrphanWorktrees } from "../../worktree/worktree-pool.js";
import { git, hasGit } from "./_helpers.js";
async function pathExists(path: string): Promise<boolean> {
try {
await access(path, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
describe.skipIf(!hasGit)("reliability interactions: defensive removal preserves unverifiable content", () => {
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
roots.length = 0;
});
async function setupRepo(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "fusion-defensive-remove-"));
roots.push(root);
git(root, "git init -b main");
git(root, 'git config user.email "test@example.com"');
git(root, 'git config user.name "Test User"');
await writeFile(join(root, "README.md"), "# repo\n", "utf-8");
await writeFile(join(root, ".gitignore"), "dist/\n", "utf-8");
git(root, "git add README.md .gitignore");
git(root, 'git commit -m "init"');
await mkdir(join(root, ".worktrees"), { recursive: true });
return root;
}
async function createWorktree(root: string, name: string): Promise<string> {
const worktreePath = join(root, ".worktrees", name);
git(root, `git worktree add -b ${JSON.stringify(`fusion/${name}`)} ${JSON.stringify(worktreePath)}`);
return worktreePath;
}
it("pool-prune refuses and preserves a dirty registered worktree", async () => {
const root = await setupRepo();
const worktreePath = await createWorktree(root, "dirty-prune");
await writeFile(join(worktreePath, "wip.txt"), "uncommitted\n", "utf-8");
await expect(
removeWorktree({ rootDir: root, worktreePath, settings: {}, reason: RemovalReason.PoolPrune }),
).rejects.toThrow(/preserving/);
expect(await readFile(join(worktreePath, "wip.txt"), "utf-8")).toBe("uncommitted\n");
});
it("idle-sweep refuses and preserves a dirty registered worktree", async () => {
const root = await setupRepo();
const worktreePath = await createWorktree(root, "dirty-idle");
await writeFile(join(worktreePath, "wip.txt"), "uncommitted\n", "utf-8");
await expect(
removeWorktree({ rootDir: root, worktreePath, settings: {}, reason: RemovalReason.SelfHealingIdleSweep }),
).rejects.toThrow(/preserving/);
expect(await pathExists(worktreePath)).toBe(true);
});
it("pool-prune preserves user content under an ignored generated-looking path", async () => {
const root = await setupRepo();
const worktreePath = await createWorktree(root, "ignored-prune");
await mkdir(join(worktreePath, "dist"), { recursive: true });
await writeFile(join(worktreePath, "dist", "manual.txt"), "precious\n", "utf-8");
await expect(
removeWorktree({ rootDir: root, worktreePath, settings: {}, reason: RemovalReason.PoolPrune }),
).rejects.toThrow(/preserving/);
expect(await readFile(join(worktreePath, "dist", "manual.txt"), "utf-8")).toBe("precious\n");
});
it.each([
RemovalReason.MergerCleanup,
RemovalReason.MergerPostMerge,
RemovalReason.SelfHealingBranchConflict,
RemovalReason.SelfHealingReclaim,
RemovalReason.SelfHealingStaleActiveBranch,
RemovalReason.StepSessionCleanup,
])("%s preserves a dirty automatically managed worktree", async (reason) => {
const root = await setupRepo();
const worktreePath = await createWorktree(root, reason);
await writeFile(join(worktreePath, "wip.txt"), "uncommitted\n", "utf-8");
await expect(removeWorktree({ rootDir: root, worktreePath, settings: {}, reason })).rejects.toThrow(/preserving/);
expect(await readFile(join(worktreePath, "wip.txt"), "utf-8")).toBe("uncommitted\n");
});
it("a failing status probe preserves the checkout instead of enabling deletion", async () => {
const root = await setupRepo();
const worktreePath = await createWorktree(root, "corrupt-probe");
// Corrupt the registration so the cleanliness probe cannot run at all.
await rm(join(root, ".git", "worktrees", "corrupt-probe"), { recursive: true, force: true });
await expect(
removeWorktree({ rootDir: root, worktreePath, settings: {}, reason: RemovalReason.PoolPrune }),
).rejects.toThrow(/preserving/);
expect(await pathExists(join(worktreePath, "README.md"))).toBe(true);
});
it("clean registered worktrees are still removed by pool-prune", async () => {
const root = await setupRepo();
const worktreePath = await createWorktree(root, "clean-prune");
await removeWorktree({ rootDir: root, worktreePath, settings: {}, reason: RemovalReason.PoolPrune });
expect(await pathExists(worktreePath)).toBe(false);
});
it("addressed teardown reasons keep their legacy forced semantics on dirty worktrees", async () => {
const root = await setupRepo();
const worktreePath = await createWorktree(root, "task-reset-dirty");
await writeFile(join(worktreePath, "wip.txt"), "uncommitted\n", "utf-8");
await removeWorktree({ rootDir: root, worktreePath, settings: {}, reason: RemovalReason.TaskReset });
expect(await pathExists(worktreePath)).toBe(false);
});
it("startup reaper preserves dangling or content orphans", async () => {
const root = await setupRepo();
const danglingOrphan = join(root, ".worktrees", "dangling-orphan");
await mkdir(danglingOrphan, { recursive: true });
await writeFile(join(danglingOrphan, ".git"), "gitdir: /nonexistent/admin\n", "utf-8");
const contentOrphan = join(root, ".worktrees", "content-orphan");
await mkdir(contentOrphan, { recursive: true });
await writeFile(join(contentOrphan, ".git"), "gitdir: /nonexistent/admin\n", "utf-8");
await writeFile(join(contentOrphan, "wip.txt"), "precious\n", "utf-8");
await reapOrphanWorktrees(root);
expect(await pathExists(join(danglingOrphan, ".git"))).toBe(true);
expect(await readFile(join(contentOrphan, "wip.txt"), "utf-8")).toBe("precious\n");
expect(dirname(contentOrphan)).toBe(join(root, ".worktrees"));
});
});

View File

@@ -5,16 +5,21 @@ import { cleanupOrphanedWorktrees } from "../../worktree/worktree-pool.js";
import { SelfHealingManager } from "../../self-healing.js";
import { NativeWorktreeBackend, WorktrunkWorktreeBackend } from "../../worktree/worktree-backend.js";
const { execSpy, existsSpy, readdirSpy, readFileSpy } = vi.hoisted(() => ({
execSpy: vi.fn(),
existsSpy: vi.fn(() => true),
readdirSpy: vi.fn(() => []),
readFileSpy: vi.fn(() => ""),
}));
const { execSpy, execFileSpy, existsSpy, readdirSpy, readFileSpy } = vi.hoisted(() => {
const execFileSpy = vi.fn().mockResolvedValue({ stdout: "", stderr: "" });
(execFileSpy as any)[Symbol.for("nodejs.util.promisify.custom")] = execFileSpy;
return {
execSpy: vi.fn(),
execFileSpy,
existsSpy: vi.fn(() => true),
readdirSpy: vi.fn(() => []),
readFileSpy: vi.fn(() => ""),
};
});
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, exec: execSpy };
return { ...actual, exec: execSpy, execFile: execFileSpy };
});
vi.mock("node:fs", async (importOriginal) => {
@@ -39,6 +44,8 @@ describe("reliability interactions: worktrunk worktree removal routing", () => {
beforeEach(() => {
vi.clearAllMocks();
execSpy.mockImplementation((_cmd: string, _opts: unknown, cb: (err: unknown, stdout: string, stderr: string) => void) => cb(null, "", ""));
execFileSpy.mockReset();
execFileSpy.mockResolvedValue({ stdout: "", stderr: "" });
// A workspace-group marker is an explicit delete veto in the ownership proof; these fixtures
// are ordinary single-project worktrees, so the marker must be absent.
existsSpy.mockImplementation(((path: string) => !String(path).endsWith("/.fusion-workspace-root")) as never);

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync, execSync } from "node:child_process";
const osState = vi.hoisted(() => ({ tempRoot: "" }));
const fsState = vi.hoisted(() => ({
@@ -174,6 +175,19 @@ function makeReclaimableWorktree(path: string, name: string): void {
writeFileSync(join(path, ".git"), `gitdir: ${join(projectRoot, ".git", "worktrees", name)}\n`);
}
function makeRealIdleWorktree(root: string, name: string): string {
// Create a genuine git worktree with admin entry so the status probe succeeds.
execSync("git init -b main", { cwd: root });
execSync('git config user.email "test@example.com"', { cwd: root });
execSync('git config user.name "Test"', { cwd: root });
writeFileSync(join(root, "README.md"), "# fixture\n");
execSync("git add README.md", { cwd: root });
execSync('git commit -m init', { cwd: root });
const worktreeDir = join(root, ".worktrees", name);
execFileSync("git", ["worktree", "add", "-b", `fusion/${name}`, worktreeDir], { cwd: root });
return worktreeDir;
}
async function sweep(manager: SelfHealingManager): Promise<number> {
return await (manager as any).cleanupStaleTempMergeWorktrees();
}
@@ -183,7 +197,7 @@ function sweepAudits(audits: any[]) {
}
describe("SelfHealingManager worktrees-dir sweeps", () => {
it("excludes internal containers from unregistered-orphan reap while removing genuine orphans", async () => {
it("excludes internal containers and preserves unverifiable unregistered orphans", async () => {
const worktreesDir = join(projectRoot, ".worktrees");
const aiMergeContainer = join(worktreesDir, ".ai-merge");
const recoveryContainer = join(worktreesDir, ".fusion-recovery");
@@ -193,12 +207,12 @@ describe("SelfHealingManager worktrees-dir sweeps", () => {
makeReclaimableWorktree(orphan, "half-built");
const { manager } = makeManager({ recycleWorktrees: true });
await expect((manager as any).reapUnregisteredOrphans()).resolves.toBe(1);
await expect((manager as any).reapUnregisteredOrphans()).resolves.toBe(0);
expect(existsSync(aiMergeContainer)).toBe(true);
expect(existsSync(recoveryContainer)).toBe(true);
expect(existsSync(orphan)).toBe(false);
expect(fsState.rmCalls).toContain(orphan);
expect(existsSync(orphan)).toBe(true);
expect(fsState.rmCalls).not.toContain(orphan);
expect(fsState.rmCalls).not.toContain(aiMergeContainer);
expect(fsState.rmCalls).not.toContain(recoveryContainer);
});
@@ -207,10 +221,9 @@ describe("SelfHealingManager worktrees-dir sweeps", () => {
const worktreesDir = join(projectRoot, ".worktrees");
const aiMergeContainer = join(worktreesDir, ".ai-merge");
const recoveryContainer = join(worktreesDir, ".fusion-recovery");
const idle = join(worktreesDir, "idle-wt");
mkdirSync(aiMergeContainer, { recursive: true });
mkdirSync(recoveryContainer, { recursive: true });
makeReclaimableWorktree(idle, "idle-wt");
makeRealIdleWorktree(projectRoot, "idle-wt");
childState.execStdout = gitWorktreeList(["idle-wt"]);
const { manager } = makeManager({ maxWorktrees: 0 });

View File

@@ -12,6 +12,7 @@ import { activeSessionRegistry } from "../agents/active-session-registry.js";
const {
execMock,
execFileMock,
accessMock,
rmMock,
chmodMock,
@@ -26,8 +27,11 @@ const {
} = vi.hoisted(() => {
const mock = vi.fn();
(mock as any)[Symbol.for("nodejs.util.promisify.custom")] = mock;
const execFileMock = vi.fn().mockResolvedValue({ stdout: "", stderr: "" });
(execFileMock as any)[Symbol.for("nodejs.util.promisify.custom")] = execFileMock;
return {
execMock: mock,
execFileMock,
accessMock: vi.fn(),
rmMock: vi.fn(),
chmodMock: vi.fn(),
@@ -42,7 +46,7 @@ const {
};
});
vi.mock("node:child_process", () => ({ exec: execMock, execFile: vi.fn() }));
vi.mock("node:child_process", () => ({ exec: execMock, execFile: execFileMock }));
vi.mock("node:fs", () => ({ existsSync: existsSyncMock }));
vi.mock("node:fs/promises", () => ({ access: accessMock, chmod: chmodMock, rm: rmMock }));
vi.mock("../execution/branch-conflicts.js", () => ({
@@ -79,6 +83,8 @@ vi.mock("../worktree/worktree-prune.js", () => ({
beforeEach(() => {
execMock.mockReset();
execFileMock.mockReset();
execFileMock.mockResolvedValue({ stdout: "", stderr: "" });
accessMock.mockReset();
rmMock.mockReset();
rmMock.mockResolvedValue(undefined as never);
@@ -278,6 +284,24 @@ describe("NativeWorktreeBackend", () => {
expect(pruneWorktreeAdminEntriesMock).not.toHaveBeenCalled();
});
it("prunes a missing defensive worktree registration without recursive fallback", async () => {
execMock.mockRejectedValueOnce({ stderr: "fatal: '/repo/.worktrees/fn-1' is not a working tree" });
existsSyncMock.mockReturnValue(false);
await new NativeWorktreeBackend().remove({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
force: false,
});
expect(rmMock).not.toHaveBeenCalled();
expect(pruneWorktreeAdminEntriesMock).toHaveBeenCalledWith(expect.objectContaining({
rootDir: "/repo",
reason: "remove-missing-fallback",
target: "/repo/.worktrees/fn-1",
}));
});
it("retries errno-only recoverable cleanup failures before pruning once", async () => {
const busy = Object.assign(new Error("busy"), { code: "EBUSY" });
execMock.mockRejectedValueOnce(busy);
@@ -954,7 +978,7 @@ describe("removeWorktree", () => {
});
expect(execMock).toHaveBeenCalledWith(
'git worktree remove --force "/repo/.worktrees/fn-1"',
'git worktree remove "/repo/.worktrees/fn-1"',
expect.objectContaining({ cwd: "/repo", timeout: 60000 }),
);
expect(audit.git).toHaveBeenCalledWith({ type: "worktree:remove", target: "/repo/.worktrees/fn-1" });

View File

@@ -36,8 +36,8 @@ afterEach(async () => {
await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
});
describe("worktree-pool secrets cleanup hooks", () => {
it("reapOrphanWorktrees invokes cleanup before removal", async () => {
describe("worktree-pool secrets preservation", () => {
it("preserves an unverifiable orphan instead of deleting its environment file", async () => {
cleanupSecretsEnvFile.mockResolvedValue({ outcome: "cleaned", reason: "fingerprint-match" });
const root = tmpRoot();
const worktrees = join(root, ".worktrees");
@@ -48,15 +48,12 @@ describe("worktree-pool secrets cleanup hooks", () => {
const mod = await import("../worktree/worktree-pool.js");
const removed = await mod.reapOrphanWorktrees(root);
expect(removed).toBe(1);
expect(cleanupSecretsEnvFile).toHaveBeenCalledWith(expect.objectContaining({
worktreePath: orphan,
taskId: "orphan:orphan-1",
}));
expect(existsSync(orphan)).toBe(false);
expect(removed).toBe(0);
expect(cleanupSecretsEnvFile).not.toHaveBeenCalled();
expect(existsSync(orphan)).toBe(true);
});
it("cleanup failures do not block orphan removal", async () => {
it("does not invoke secrets cleanup before preserving dangling metadata", async () => {
cleanupSecretsEnvFile.mockRejectedValueOnce(new Error("cleanup failed"));
const root = tmpRoot();
const orphan = join(root, ".worktrees", "orphan-2");
@@ -65,7 +62,8 @@ describe("worktree-pool secrets cleanup hooks", () => {
const mod = await import("../worktree/worktree-pool.js");
const removed = await mod.reapOrphanWorktrees(root);
expect(removed).toBe(1);
expect(existsSync(orphan)).toBe(false);
expect(removed).toBe(0);
expect(cleanupSecretsEnvFile).not.toHaveBeenCalled();
expect(existsSync(orphan)).toBe(true);
});
});

View File

@@ -1140,7 +1140,7 @@ describe("cleanupOrphanedWorktrees", () => {
expect(removeCalls).toHaveLength(0);
});
it("excludes internal containers while still removing genuine unregistered orphans", async () => {
it("excludes internal containers and preserves unregistered orphans", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry(".ai-merge"),
makeDirEntry(".fusion-recovery"),
@@ -1152,16 +1152,13 @@ describe("cleanupOrphanedWorktrees", () => {
const cleaned = await cleanupOrphanedWorktrees("/root", store);
expect(cleaned).toBe(1);
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/broken-wt", {
recursive: true,
force: true,
});
expect(cleaned).toBe(0);
expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/broken-wt", expect.anything());
expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/.ai-merge", expect.anything());
expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/.fusion-recovery", expect.anything());
});
it("removes unregistered directories even when stale active task metadata references them", async () => {
it("preserves unregistered directories referenced by stale active task metadata", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("broken-wt"),
] as any);
@@ -1173,14 +1170,9 @@ describe("cleanupOrphanedWorktrees", () => {
const cleaned = await cleanupOrphanedWorktrees("/root", store);
expect(cleaned).toBe(1);
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/broken-wt", {
recursive: true,
force: true,
});
expect(mockedPruneWorktreeAdminEntries).toHaveBeenCalledWith(
expect.objectContaining({ reason: "pool-cleanup-orphan", target: "/root/.worktrees/broken-wt" }),
);
expect(cleaned).toBe(0);
expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/broken-wt", expect.anything());
expect(mockedPruneWorktreeAdminEntries).not.toHaveBeenCalled();
});
});
@@ -1207,13 +1199,8 @@ describe("reapOrphanWorktrees", () => {
expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/.fusion-recovery", expect.anything());
});
// FN-6782 follow-up: a directory whose `.git` points to a missing admin entry is leak
// residue (invisible to `git worktree list`/`prune`), not "partially registered". It
// must be reaped — otherwise it collides with freshly generated worktree names and
// breaks `execute`. Previously the reaper skipped on mere `.git` presence.
it("reaps a dir with a dangling .git pointer (admin gitdir missing)", async () => {
it("preserves a dir with a dangling .git pointer", async () => {
mockedReaddirSync.mockReturnValue([makeDirEntry("leaked-wt")] as any);
// `.git` is a link FILE (not a dir); the worktree dir itself is a dir.
mockedLstatSync.mockImplementation((p: any) =>
(String(p).endsWith("/.git")
? { isDirectory: () => false, isSymbolicLink: () => false }
@@ -1222,14 +1209,13 @@ describe("reapOrphanWorktrees", () => {
mockedReadFileSync.mockReturnValue("gitdir: /root/.git/worktrees/leaked-wt\n" as any);
mockedExistsSync.mockImplementation((p) => {
const s = String(p);
// .worktrees root exists; the .git link file exists; the gitdir target does NOT.
return s === "/root/.worktrees" || s === "/root/.worktrees/leaked-wt/.git";
});
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(1);
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/leaked-wt", { recursive: true, force: true });
expect(removed).toBe(0);
expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/leaked-wt", expect.anything());
});
it("skips a dir with a valid .git pointer (admin gitdir exists)", async () => {

View File

@@ -26,7 +26,7 @@
import { execSync } from "node:child_process";
import { setImmediate as setImmediateCb } from "node:timers";
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -16686,7 +16686,9 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
continue;
}
try {
rmSync(path, { recursive: true, force: true });
// FNXC:WorktreeCleanup: rmdir is deliberately non-recursive. Any content
// makes it fail closed and preserves the unregistered checkout.
rmdirSync(path);
log.log(`Cleaned unregistered worktree dir: ${path}`);
cleaned++;
} catch (err: unknown) {

View File

@@ -1,4 +1,4 @@
import { exec } from "node:child_process";
import { exec, execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { access, rm } from "node:fs/promises";
import { basename, resolve } from "node:path";
@@ -27,6 +27,7 @@ import {
import { parseStaleRegistrationPath, recoverStaleRegistration } from "./worktree-stale-registration.js";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const NATIVE_TIMEOUT_MS = 120_000;
const REMOVE_TIMEOUT_MS = 60_000;
const MAX_BUFFER = 10 * 1024 * 1024;
@@ -216,6 +217,7 @@ export interface WorktreeRemoveInput {
worktreePath: string;
branch?: string;
taskId?: string;
force?: boolean;
}
export interface WorktreeSyncInput {
@@ -679,7 +681,7 @@ export class NativeWorktreeBackend implements WorktreeBackend {
async remove(input: WorktreeRemoveInput): Promise<void> {
try {
await execAsync(`git worktree remove --force ${quoteShellArg(input.worktreePath)}`, {
await execAsync(`git worktree remove${input.force === false ? "" : " --force"} ${quoteShellArg(input.worktreePath)}`, {
cwd: input.rootDir,
encoding: "utf-8",
timeout: REMOVE_TIMEOUT_MS,
@@ -687,6 +689,22 @@ export class NativeWorktreeBackend implements WorktreeBackend {
});
return;
} catch (error) {
// Defensive callers rely on Git's deletion-boundary dirty check. Never turn
// that refusal into the recursive filesystem fallback below.
if (input.force === false) {
const missingPathError = /is not a working tree|no such file or directory|does not exist/i.test(getErrorMessageWithStderr(error));
if (!existsSync(input.worktreePath) && missingPathError) {
await pruneWorktreeAdminEntries({
rootDir: input.rootDir,
auditor: this.deps.audit,
reason: "remove-missing-fallback",
target: input.worktreePath,
logger: this.deps.logger,
});
return;
}
throw error;
}
if (!isRecoverableNativeWorktreeRemoveError(error)) {
throw error;
}
@@ -951,7 +969,7 @@ export class WorktrunkWorktreeBackend implements WorktreeBackend {
async remove(input: WorktreeRemoveInput): Promise<void> {
const target = input.branch ?? input.worktreePath;
try {
await this.runWorktrunk(["remove", "--foreground", target], {
await this.runWorktrunk(["remove", "--foreground", ...(input.force === true ? ["--force"] : []), target], {
cwd: input.rootDir,
operation: "remove",
});
@@ -1072,6 +1090,17 @@ const ALLOWED_FORCE_REASONS = new Set<RemovalReason>([
RemovalReason.WorkspaceAcquireRollback,
]);
const DEFENSIVE_REMOVAL_REASONS = new Set<RemovalReason>([
RemovalReason.MergerCleanup,
RemovalReason.MergerPostMerge,
RemovalReason.PoolPrune,
RemovalReason.SelfHealingBranchConflict,
RemovalReason.SelfHealingIdleSweep,
RemovalReason.SelfHealingReclaim,
RemovalReason.SelfHealingStaleActiveBranch,
RemovalReason.StepSessionCleanup,
]);
export class InvalidForceUsageError extends Error {
constructor(reason: RemovalReason) {
super(`force=true is not allowed for removal reason '${reason}'`);
@@ -1092,6 +1121,28 @@ export class ActiveSessionWorktreeRemovalError extends Error {
}
}
/** Fail closed when an automatic sweep cannot prove the checkout is empty of user content. */
async function assertCleanForDefensiveRemoval(worktreePath: string): Promise<void> {
// Nothing on disk means nothing to preserve — stale registrations prune normally below.
if (!existsSync(worktreePath)) {
return;
}
let stdout: string;
try {
({ stdout } = await execFileAsync("git", ["status", "--porcelain", "--ignored", "--untracked-files=all"], {
cwd: worktreePath,
encoding: "utf-8",
timeout: 15_000,
maxBuffer: MAX_BUFFER,
}));
} catch (error) {
throw new Error(`preserving ${worktreePath}: status probe failed (${error instanceof Error ? error.message : String(error)})`);
}
if (stdout.trim().length > 0) {
throw new Error(`preserving ${worktreePath}: uncommitted or ignored content present`);
}
}
/**
* FNXC:WorkspaceWorktree 2026-08-20-07:08:
* Force removal is reserved for explicit executor teardown paths and workspace-acquisition rollback
@@ -1120,6 +1171,15 @@ export async function removeWorktree(input: {
throw new InvalidForceUsageError(input.reason);
}
const requiresCleanWorktree = DEFENSIVE_REMOVAL_REASONS.has(input.reason);
// FNXC:WorktreeCleanup:
// Defensive sweeps must prove cleanliness before destroying anything. Dirty or
// unverifiable content is preserved (fail closed) — callers treat the throw as "kept".
if (requiresCleanWorktree) {
await assertCleanForDefensiveRemoval(input.worktreePath);
}
if (input.expectedOwnerTaskId && input.liveOwnerProbe) {
const reconciled = reconcileSelfOwnedActiveSessionForRemoval(
activeSessionRegistry,
@@ -1169,6 +1229,7 @@ export async function removeWorktree(input: {
rootDir: input.rootDir,
worktreePath: input.worktreePath,
taskId: input.taskId,
force: requiresCleanWorktree ? false : input.force,
};
if (input.force === false || typeof input.timeout === "number") {

View File

@@ -1,6 +1,6 @@
import { exec, execFile } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, realpathSync } from "node:fs";
import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync, realpathSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { basename, dirname, join, relative, resolve, isAbsolute } from "node:path";
import type { SecretsStore, Settings, TaskStore, WorktrunkSettings, WorkspaceWorktreeContext } from "@fusion/core";
@@ -18,7 +18,6 @@ import {
removeWorktree as removeWorktreeViaBackend,
resolveWorktreeBackend as resolveWorktreeBackendViaSettings,
} from "./worktree-backend.js";
import { cleanupSecretsEnvFile } from "./secrets-env-writer.js";
import { removeDesktopBuildArtifacts } from "./worktree-desktop-artifacts.js";
import { resolveIntegrationBranch } from "../merge/integration-branch.js";
import type { RunAuditor } from "../util/run-audit.js";
@@ -1026,22 +1025,6 @@ export async function cleanupOrphanedWorktrees(
for (const worktreePath of candidates) {
try {
if (registeredWorktrees.has(resolve(worktreePath))) {
const orphanTaskId = `orphan:${basename(worktreePath)}`;
try {
await cleanupSecretsEnvFile({
worktreePath,
taskId: orphanTaskId,
expectedFingerprint: null,
filename: ".env",
audit: undefined,
logger: worktreePoolLog,
});
} catch (error) {
worktreePoolLog.warn(
`secrets-env cleanup failed for registered orphan ${worktreePath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
await removeWorktreeViaBackend({
rootDir,
worktreePath,
@@ -1052,7 +1035,9 @@ export async function cleanupOrphanedWorktrees(
if (!isInsideWorktreesDir(rootDir, worktreePath, settings)) {
throw new Error(`Refusing to remove path outside .worktrees: ${worktreePath}`);
}
rmSync(worktreePath, { recursive: true, force: true });
// FNXC:WorktreeCleanup: rmdir is deliberately non-recursive. Any content
// makes it fail closed and preserves the unregistered checkout.
rmdirSync(worktreePath);
await pruneWorktreeAdminEntries({
rootDir,
reason: "pool-cleanup-orphan",
@@ -1199,36 +1184,26 @@ export async function reapOrphanWorktrees(
continue;
}
worktreePoolLog.debug(`reapOrphanWorktrees: ${name} has a dangling .git pointer (admin entry missing) — treating as orphan`);
// fall through to removal
// fall through to the non-recursive removal below; `.git` makes it fail closed.
}
// This directory is on disk but has no valid .git entry and is not a registered
// worktree — it is a half-initialized / leaked orphan. Remove it.
try {
try {
await cleanupSecretsEnvFile({
worktreePath: resolvedFull,
taskId: `orphan:${name}`,
expectedFingerprint: null,
filename: ".env",
logger: worktreePoolLog,
});
} catch (error) {
worktreePoolLog.warn(`secrets-env cleanup failed for orphan ${name}: ${error instanceof Error ? error.message : String(error)}`);
}
rmSync(resolvedFull, { recursive: true, force: true });
await pruneWorktreeAdminEntries({
rootDir: projectRoot,
reason: "pool-reap-orphan",
target: resolvedFull,
logger: worktreePoolLog,
}).catch(() => undefined);
worktreePoolLog.log(`reapOrphanWorktrees: removed half-initialized orphan ${name}`);
removed++;
rmdirSync(resolvedFull);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
worktreePoolLog.warn(`reapOrphanWorktrees: failed to remove ${name} — ${msg}`);
continue;
}
await pruneWorktreeAdminEntries({
rootDir: projectRoot,
reason: "pool-reap-orphan",
target: resolvedFull,
logger: worktreePoolLog,
}).catch(() => undefined);
worktreePoolLog.log(`reapOrphanWorktrees: removed half-initialized orphan ${name}`);
removed++;
}
return removed;