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:
7
.changeset/fn-9207-chat-focus-popover-mobile.md
Normal file
7
.changeset/fn-9207-chat-focus-popover-mobile.md
Normal 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.
|
||||
7
.changeset/fn-9208-chat-focus-icon-only.md
Normal file
7
.changeset/fn-9208-chat-focus-icon-only.md
Normal 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.
|
||||
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.
|
||||
7
.changeset/preserve-dirty-worktrees-minimal.md
Normal file
7
.changeset/preserve-dirty-worktrees-minimal.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
})
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user