feat(FN-3584): add memory file markdown preview to agent detail and log vie
This merge introduces a memory file markdown preview feature (FN-3584) with corresponding documentation, refines the AgentDetailView and AgentLogViewer components in the dashboard, and adds defensive collision handling for worktree operations during manual task moves (FN-3583). Fusion-Task-Id: FN-3584
This commit is contained in:
5
.changeset/fn-3582-fix-duplicate-merge-notifications.md
Normal file
5
.changeset/fn-3582-fix-duplicate-merge-notifications.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix duplicate ntfy merge notifications by ensuring `ProjectEngine` uses a single `NotificationService` listener graph and passes that shared service into the `NtfyNotifier` compatibility shim.
|
||||||
@@ -354,6 +354,8 @@ For long-form prompt authoring, **Soul**, **Agent Memory**, and **Inline Instruc
|
|||||||
- Plain/edit mode and Markdown preview mode
|
- Plain/edit mode and Markdown preview mode
|
||||||
- Fullscreen expand/collapse editing for long content (safe-area-aware on mobile)
|
- Fullscreen expand/collapse editing for long content (safe-area-aware on mobile)
|
||||||
|
|
||||||
|
In Agent Detail → **Agent Memory** → **Memory Files**, selected file content now also supports the same **Edit/Preview** markdown toggle. Preview renders the current in-memory draft (including unsaved edits), while save/edit controls remain gated by agent read-only state.
|
||||||
|
|
||||||
These controls are also available on the editable review step, so prompt content can be reviewed and refined with the same markdown and fullscreen behavior before submit.
|
These controls are also available on the editable review step, so prompt content can be reviewed and refined with the same markdown and fullscreen behavior before submit.
|
||||||
|
|
||||||
### Final review edits (step 2)
|
### Final review edits (step 2)
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
|
|||||||
- `RunAudit` (`run-audit.ts`) — mutation audit tracking (DB/git/filesystem)
|
- `RunAudit` (`run-audit.ts`) — mutation audit tracking (DB/git/filesystem)
|
||||||
- `Notifier` (`notifier.ts`) — legacy ntfy compatibility shim (`NtfyNotifier`) plus shared ntfy helpers
|
- `Notifier` (`notifier.ts`) — legacy ntfy compatibility shim (`NtfyNotifier`) plus shared ntfy helpers
|
||||||
- Runtime ownership: `NtfyNotifier` no longer owns an independent task-lifecycle listener graph; `ProjectEngine` injects the canonical `NotificationService` instance so task lifecycle notifications (`task:moved`, `task:updated`, `task:merged`) are emitted through a single path.
|
- Runtime ownership: `NtfyNotifier` no longer owns an independent task-lifecycle listener graph; `ProjectEngine` injects the canonical `NotificationService` instance so task lifecycle notifications (`task:moved`, `task:updated`, `task:merged`) are emitted through a single path.
|
||||||
|
- Merge dedup safety: `ProjectEngine.start()` is idempotent, so repeated start calls do not wire a second `NotificationService`/`NtfyNotifier` pair. A successful merge therefore emits exactly one canonical `merged` ntfy lifecycle notification per task.
|
||||||
- Compatibility scope: `NtfyNotifier` remains responsible for gridlock-only compatibility notifications (`notifyGridlock`) and legacy helper APIs.
|
- Compatibility scope: `NtfyNotifier` remains responsible for gridlock-only compatibility notifications (`notifyGridlock`) and legacy helper APIs.
|
||||||
- Legacy gridlock ntfy delivery is cooldown-throttled: first detection notifies immediately, subsequent detections are suppressed for 15 minutes (even if blocked-task membership changes), and the cooldown resets as soon as gridlock fully clears.
|
- Legacy gridlock ntfy delivery is cooldown-throttled: first detection notifies immediately, subsequent detections are suppressed for 15 minutes (even if blocked-task membership changes), and the cooldown resets as soon as gridlock fully clears.
|
||||||
- `NotificationService` (`notification/notification-service.ts`) — provider lifecycle + event dispatch orchestration
|
- `NotificationService` (`notification/notification-service.ts`) — provider lifecycle + event dispatch orchestration
|
||||||
|
|||||||
@@ -1957,6 +1957,7 @@ function MemoryTab({
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [justSaved, setJustSaved] = useState(false);
|
const [justSaved, setJustSaved] = useState(false);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [showFilePreview, setShowFilePreview] = useState(false);
|
||||||
|
|
||||||
const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]);
|
const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]);
|
||||||
const [memoryFilesLoading, setMemoryFilesLoading] = useState(false);
|
const [memoryFilesLoading, setMemoryFilesLoading] = useState(false);
|
||||||
@@ -2027,6 +2028,7 @@ function MemoryTab({
|
|||||||
setMemory(agent.memory ?? "");
|
setMemory(agent.memory ?? "");
|
||||||
setJustSaved(false);
|
setJustSaved(false);
|
||||||
setShowPreview(false);
|
setShowPreview(false);
|
||||||
|
setShowFilePreview(false);
|
||||||
setFileSwitchHint("");
|
setFileSwitchHint("");
|
||||||
setSelectedFileJustSaved(false);
|
setSelectedFileJustSaved(false);
|
||||||
void loadMemoryFiles();
|
void loadMemoryFiles();
|
||||||
@@ -2220,19 +2222,58 @@ function MemoryTab({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<textarea
|
<div className="agent-content-toolbar config-textarea-top-spacing">
|
||||||
className="input config-textarea-mono config-textarea-top-spacing"
|
<div className="agent-content-mode-toggle">
|
||||||
rows={14}
|
{!isReadOnly && (
|
||||||
placeholder="Select a memory file to view and edit its content..."
|
<button
|
||||||
value={selectedFileContent}
|
className={`btn btn-sm ${!showFilePreview ? "btn-primary" : ""}`}
|
||||||
readOnly={isReadOnly || !selectedFilePath || selectedFileLoading}
|
onClick={() => setShowFilePreview(false)}
|
||||||
onChange={(e) => {
|
disabled={!showFilePreview}
|
||||||
setSelectedFileContent(e.target.value);
|
aria-label="Memory file edit mode"
|
||||||
setSelectedFileDirty(true);
|
>
|
||||||
setSelectedFileJustSaved(false);
|
<FileEdit size={14} />
|
||||||
setFileSwitchHint("");
|
Edit
|
||||||
}}
|
</button>
|
||||||
/>
|
)}
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${showFilePreview ? "btn-primary" : ""}`}
|
||||||
|
onClick={() => setShowFilePreview(true)}
|
||||||
|
disabled={showFilePreview}
|
||||||
|
aria-label="Memory file preview mode"
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFilePreview ? (
|
||||||
|
selectedFileContent.trim() ? (
|
||||||
|
<div className="agent-content-preview markdown-body">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{selectedFileContent}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="agent-content-preview agent-content-placeholder">
|
||||||
|
No memory file content yet. Switch to Edit mode to add content.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
className="input config-textarea-mono"
|
||||||
|
rows={14}
|
||||||
|
placeholder="Select a memory file to view and edit its content..."
|
||||||
|
value={selectedFileContent}
|
||||||
|
readOnly={isReadOnly || !selectedFilePath || selectedFileLoading}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedFileContent(e.target.value);
|
||||||
|
setSelectedFileDirty(true);
|
||||||
|
setSelectedFileJustSaved(false);
|
||||||
|
setFileSwitchHint("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedFileLoading && (
|
{selectedFileLoading && (
|
||||||
<span className="config-hint config-hint--inline-loader">
|
<span className="config-hint config-hint--inline-loader">
|
||||||
@@ -2269,23 +2310,25 @@ function MemoryTab({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{!showFilePreview && (
|
||||||
className="btn"
|
<button
|
||||||
disabled={!selectedFileDirty || savingSelectedFile || !selectedFilePath || isReadOnly}
|
className="btn"
|
||||||
onClick={() => void handleSaveSelectedMemoryFile()}
|
disabled={!selectedFileDirty || savingSelectedFile || !selectedFilePath || isReadOnly}
|
||||||
>
|
onClick={() => void handleSaveSelectedMemoryFile()}
|
||||||
{savingSelectedFile ? (
|
>
|
||||||
<>
|
{savingSelectedFile ? (
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<>
|
||||||
Saving file…
|
<Loader2 size={16} className="animate-spin" />
|
||||||
</>
|
Saving file…
|
||||||
) : (
|
</>
|
||||||
<>
|
) : (
|
||||||
<CheckCircle size={16} />
|
<>
|
||||||
Save Memory File
|
<CheckCircle size={16} />
|
||||||
</>
|
Save Memory File
|
||||||
)}
|
</>
|
||||||
</button>
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!hasInlineChanges && justSaved && (
|
{!hasInlineChanges && justSaved && (
|
||||||
<span className="config-saved-indicator">
|
<span className="config-saved-indicator">
|
||||||
<CheckCircle size={14} />
|
<CheckCircle size={14} />
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ vi.mock("../../api", () => ({
|
|||||||
updateAgentInstructions: vi.fn(),
|
updateAgentInstructions: vi.fn(),
|
||||||
updateAgentSoul: vi.fn(),
|
updateAgentSoul: vi.fn(),
|
||||||
updateAgentMemory: vi.fn(),
|
updateAgentMemory: vi.fn(),
|
||||||
|
fetchAgentMemoryFiles: vi.fn(),
|
||||||
|
fetchAgentMemoryFile: vi.fn(),
|
||||||
|
saveAgentMemoryFile: vi.fn(),
|
||||||
fetchAgentTasks: vi.fn(),
|
fetchAgentTasks: vi.fn(),
|
||||||
fetchChainOfCommand: vi.fn(),
|
fetchChainOfCommand: vi.fn(),
|
||||||
fetchAgentBudgetStatus: vi.fn(),
|
fetchAgentBudgetStatus: vi.fn(),
|
||||||
@@ -119,7 +122,7 @@ vi.mock("../../hooks/useConfirm", () => ({
|
|||||||
useConfirm: () => ({ confirm: mockConfirm }),
|
useConfirm: () => ({ confirm: mockConfirm }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchCompanies } from "../../api";
|
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchCompanies } from "../../api";
|
||||||
import { subscribeSse } from "../../sse-bus";
|
import { subscribeSse } from "../../sse-bus";
|
||||||
|
|
||||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||||
@@ -138,6 +141,9 @@ const mockResetAgentBudget = vi.mocked(resetAgentBudget);
|
|||||||
const mockUpdateAgentInstructions = vi.mocked(updateAgentInstructions);
|
const mockUpdateAgentInstructions = vi.mocked(updateAgentInstructions);
|
||||||
const mockUpdateAgentSoul = vi.mocked(updateAgentSoul);
|
const mockUpdateAgentSoul = vi.mocked(updateAgentSoul);
|
||||||
const mockUpdateAgentMemory = vi.mocked(updateAgentMemory);
|
const mockUpdateAgentMemory = vi.mocked(updateAgentMemory);
|
||||||
|
const mockFetchAgentMemoryFiles = vi.mocked(fetchAgentMemoryFiles);
|
||||||
|
const mockFetchAgentMemoryFile = vi.mocked(fetchAgentMemoryFile);
|
||||||
|
const mockSaveAgentMemoryFile = vi.mocked(saveAgentMemoryFile);
|
||||||
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
|
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
|
||||||
const mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent);
|
const mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent);
|
||||||
const mockFetchDiscoveredSkills = vi.mocked(fetchDiscoveredSkills);
|
const mockFetchDiscoveredSkills = vi.mocked(fetchDiscoveredSkills);
|
||||||
@@ -231,6 +237,22 @@ describe("AgentDetailView", () => {
|
|||||||
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "", mtime: "2024-01-01T00:00:00.000Z", size: 0 });
|
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "", mtime: "2024-01-01T00:00:00.000Z", size: 0 });
|
||||||
mockSaveWorkspaceFileContent.mockResolvedValue({ success: true, mtime: "2024-01-01T00:00:00.000Z", size: 0 });
|
mockSaveWorkspaceFileContent.mockResolvedValue({ success: true, mtime: "2024-01-01T00:00:00.000Z", size: 0 });
|
||||||
mockUpdateAgentInstructions.mockResolvedValue({} as any);
|
mockUpdateAgentInstructions.mockResolvedValue({} as any);
|
||||||
|
mockFetchAgentMemoryFiles.mockResolvedValue({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
path: ".fusion/agent-memory/agent-001/MEMORY.md",
|
||||||
|
label: "MEMORY.md",
|
||||||
|
layer: "long-term",
|
||||||
|
size: 12,
|
||||||
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any);
|
||||||
|
mockFetchAgentMemoryFile.mockResolvedValue({
|
||||||
|
path: ".fusion/agent-memory/agent-001/MEMORY.md",
|
||||||
|
content: "",
|
||||||
|
} as any);
|
||||||
|
mockSaveAgentMemoryFile.mockResolvedValue({ success: true } as any);
|
||||||
// Default: return skills
|
// Default: return skills
|
||||||
mockFetchDiscoveredSkills.mockResolvedValue(MOCK_SKILLS);
|
mockFetchDiscoveredSkills.mockResolvedValue(MOCK_SKILLS);
|
||||||
mockFetchSkillContent.mockResolvedValue({ name: "Skill", skillMd: "# Skill", files: [] });
|
mockFetchSkillContent.mockResolvedValue({ name: "Skill", skillMd: "# Skill", files: [] });
|
||||||
@@ -4248,55 +4270,26 @@ describe("AgentDetailView", () => {
|
|||||||
|
|
||||||
it("renders Memory tab with textarea by default", async () => {
|
it("renders Memory tab with textarea by default", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
<AgentDetailView
|
|
||||||
agentId="agent-001"
|
|
||||||
onClose={vi.fn()}
|
|
||||||
addToast={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await navigateToMemory(user);
|
await navigateToMemory(user);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
|
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Edit")).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Edit mode" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("Preview")).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Preview mode" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles between edit and preview mode", async () => {
|
it("toggles between edit and preview mode", async () => {
|
||||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
mockFetchAgent.mockResolvedValue(createMockAgent({ memory: "# Agent Memory\n\n- Item 1\n- Item 2" }));
|
||||||
memory: "# Agent Memory\n\n- Item 1\n- Item 2",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
<AgentDetailView
|
|
||||||
agentId="agent-001"
|
|
||||||
onClose={vi.fn()}
|
|
||||||
addToast={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await navigateToMemory(user);
|
await navigateToMemory(user);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Preview mode" }));
|
||||||
// Default: edit mode
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click Preview
|
|
||||||
await user.click(screen.getByText("Preview"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByLabelText("Agent Memory")).not.toBeInTheDocument();
|
expect(screen.queryByLabelText("Agent Memory")).not.toBeInTheDocument();
|
||||||
expect(document.querySelector(".markdown-body")).toBeInTheDocument();
|
expect(document.querySelector(".markdown-body")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
await user.click(screen.getByRole("button", { name: "Edit mode" }));
|
||||||
// Click Edit
|
|
||||||
await user.click(screen.getByText("Edit"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
|
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -4304,18 +4297,9 @@ describe("AgentDetailView", () => {
|
|||||||
|
|
||||||
it("shows placeholder when memory is empty in preview mode", async () => {
|
it("shows placeholder when memory is empty in preview mode", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
<AgentDetailView
|
|
||||||
agentId="agent-001"
|
|
||||||
onClose={vi.fn()}
|
|
||||||
addToast={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await navigateToMemory(user);
|
await navigateToMemory(user);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Preview mode" }));
|
||||||
await user.click(screen.getByText("Preview"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("No agent memory defined yet. Switch to Edit mode to add memory content.")).toBeInTheDocument();
|
expect(screen.getByText("No agent memory defined yet. Switch to Edit mode to add memory content.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -4323,126 +4307,83 @@ describe("AgentDetailView", () => {
|
|||||||
|
|
||||||
it("hides save button when in preview mode", async () => {
|
it("hides save button when in preview mode", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
<AgentDetailView
|
|
||||||
agentId="agent-001"
|
|
||||||
onClose={vi.fn()}
|
|
||||||
addToast={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await navigateToMemory(user);
|
await navigateToMemory(user);
|
||||||
|
expect(screen.getByText("Save Memory")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Preview mode" }));
|
||||||
|
await waitFor(() => expect(screen.queryByText("Save Memory")).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides inline Edit button when agent is running", async () => {
|
||||||
|
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running", memory: "This agent has memory." }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
await navigateToMemory(user);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Save Memory")).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Preview mode" })).toBeInTheDocument();
|
||||||
});
|
expect(screen.queryByRole("button", { name: "Edit mode" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByText("Preview"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText("Save Memory")).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides Edit button when agent is running", async () => {
|
it("can switch inline memory to preview mode when agent is running", async () => {
|
||||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running", memory: "Agent memory content" }));
|
||||||
state: "running",
|
|
||||||
memory: "This agent has memory.",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
<AgentDetailView
|
|
||||||
agentId="agent-001"
|
|
||||||
onClose={vi.fn()}
|
|
||||||
addToast={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await navigateToMemory(user);
|
await navigateToMemory(user);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Preview mode" }));
|
||||||
|
await waitFor(() => expect(document.querySelector(".markdown-body")).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
// Preview button should be visible, Edit button should be hidden
|
it("renders memory file preview markdown and toggles back to edit", async () => {
|
||||||
|
mockFetchAgentMemoryFile.mockResolvedValue({ path: ".fusion/agent-memory/agent-001/MEMORY.md", content: "# Heading\n\n- entry" } as any);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
await navigateToMemory(user);
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Memory file preview mode" }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Preview")).toBeInTheDocument();
|
expect(screen.queryByPlaceholderText("Select a memory file to view and edit its content...")).not.toBeInTheDocument();
|
||||||
// Edit button should not be in the DOM (not just disabled, hidden)
|
expect(screen.getByText("Heading")).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: /Edit/i })).not.toBeInTheDocument();
|
expect(screen.queryByText("Save Memory File")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await user.click(screen.getByRole("button", { name: "Memory file edit mode" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByPlaceholderText("Select a memory file to view and edit its content...")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows Preview button but not Edit when agent is running", async () => {
|
it("shows memory file preview placeholder when selected file is empty", async () => {
|
||||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
mockFetchAgentMemoryFile.mockResolvedValue({ path: ".fusion/agent-memory/agent-001/MEMORY.md", content: "" } as any);
|
||||||
state: "running",
|
|
||||||
memory: "Agent memory content",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
<AgentDetailView
|
|
||||||
agentId="agent-001"
|
|
||||||
onClose={vi.fn()}
|
|
||||||
addToast={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await navigateToMemory(user);
|
await navigateToMemory(user);
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Memory file preview mode" }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Preview button is visible
|
expect(screen.getByText("No memory file content yet. Switch to Edit mode to add content.")).toBeInTheDocument();
|
||||||
const previewBtn = screen.getByRole("button", { name: /Preview/i });
|
|
||||||
expect(previewBtn).toBeInTheDocument();
|
|
||||||
// Edit button should be hidden
|
|
||||||
expect(screen.queryByRole("button", { name: /Edit/i })).not.toBeInTheDocument();
|
|
||||||
// Since Edit is hidden and default is edit mode, the textarea should still be visible
|
|
||||||
// but user needs to click Preview to see the markdown render
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can switch to preview mode when agent is running", async () => {
|
it("hides memory file edit button and disables save button for running agents", async () => {
|
||||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running" }));
|
||||||
state: "running",
|
|
||||||
memory: "Agent memory content",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
<AgentDetailView
|
|
||||||
agentId="agent-001"
|
|
||||||
onClose={vi.fn()}
|
|
||||||
addToast={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await navigateToMemory(user);
|
await navigateToMemory(user);
|
||||||
|
|
||||||
// Click Preview button
|
|
||||||
await user.click(screen.getByText("Preview"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(document.querySelector(".markdown-body")).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Memory file preview mode" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Memory file edit mode" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Save Memory File" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls updateAgentMemory when saving memory", async () => {
|
it("calls updateAgentMemory when saving memory", async () => {
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
mockUpdateAgentMemory.mockResolvedValue({} as any);
|
mockUpdateAgentMemory.mockResolvedValue({} as any);
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={addToast} />);
|
||||||
<AgentDetailView
|
|
||||||
agentId="agent-001"
|
|
||||||
onClose={vi.fn()}
|
|
||||||
addToast={addToast}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await navigateToMemory(user);
|
await navigateToMemory(user);
|
||||||
|
|
||||||
const textarea = await screen.findByLabelText("Agent Memory");
|
const textarea = await screen.findByLabelText("Agent Memory");
|
||||||
await user.clear(textarea);
|
await user.clear(textarea);
|
||||||
await user.type(textarea, "This is the agent's new memory");
|
await user.type(textarea, "This is the agent's new memory");
|
||||||
|
|
||||||
await user.click(screen.getByText("Save Memory"));
|
await user.click(screen.getByText("Save Memory"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUpdateAgentMemory).toHaveBeenCalledWith("agent-001", "This is the agent's new memory", undefined);
|
expect(mockUpdateAgentMemory).toHaveBeenCalledWith("agent-001", "This is the agent's new memory", undefined);
|
||||||
expect(addToast).toHaveBeenCalledWith("Memory saved", "success");
|
expect(addToast).toHaveBeenCalledWith("Memory saved", "success");
|
||||||
|
|||||||
@@ -128,4 +128,36 @@ describe("NotificationService", () => {
|
|||||||
expect(initSpy).not.toHaveBeenCalled();
|
expect(initSpy).not.toHaveBeenCalled();
|
||||||
initSpy.mockRestore();
|
initSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("duplicates merged dispatch when multiple NotificationService instances subscribe to the same store", async () => {
|
||||||
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||||
|
const provider: NotificationProvider = {
|
||||||
|
getProviderId: () => "mock",
|
||||||
|
isEventSupported: () => true,
|
||||||
|
sendNotification,
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = new NotificationService(store as any);
|
||||||
|
const second = new NotificationService(store as any);
|
||||||
|
first.registerProvider(provider);
|
||||||
|
second.registerProvider(provider);
|
||||||
|
await first.start();
|
||||||
|
await second.start();
|
||||||
|
|
||||||
|
// Confirms duplication is from duplicate listener graphs, not duplicate task:merged payloads.
|
||||||
|
store.emit("task:merged", {
|
||||||
|
task: task(),
|
||||||
|
branch: "fusion/fn-1",
|
||||||
|
merged: true,
|
||||||
|
worktreeRemoved: true,
|
||||||
|
branchDeleted: true,
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(sendNotification).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
await first.stop();
|
||||||
|
await second.stop();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1093,7 +1093,7 @@ describe("NtfyNotifier", () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits a single merged notification when notifier shares an already-started NotificationService", async () => {
|
it("emits a single merged notification when notifier shares the same already-started NotificationService (ProjectEngine wiring)", async () => {
|
||||||
const sharedService = new NotificationService(store, { projectId: "proj-1" });
|
const sharedService = new NotificationService(store, { projectId: "proj-1" });
|
||||||
await sharedService.start();
|
await sharedService.start();
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,22 @@ describe("ProjectEngine notification ownership wiring", () => {
|
|||||||
expect(mocks.notificationServiceStop).toHaveBeenCalledTimes(1);
|
expect(mocks.notificationServiceStop).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.notifierStop).toHaveBeenCalledTimes(1);
|
expect(mocks.notifierStop).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not recreate notification listeners on repeated start calls, preventing duplicate merged delivery", async () => {
|
||||||
|
const engine = createEngine({ skipNotifier: false, projectId: "proj_for_notifier" });
|
||||||
|
|
||||||
|
await engine.start();
|
||||||
|
await engine.start();
|
||||||
|
|
||||||
|
// Root cause guard: if ProjectEngine.start is called more than once, it should not
|
||||||
|
// wire a second NotificationService/NtfyNotifier pair for the same store.
|
||||||
|
expect(NotificationService).toHaveBeenCalledTimes(1);
|
||||||
|
expect(NtfyNotifier).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.notificationServiceStart).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.notifierStart).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await engine.stop();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ProjectEngine PR monitoring wiring", () => {
|
describe("ProjectEngine PR monitoring wiring", () => {
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export interface ProjectEngineOptions {
|
|||||||
*/
|
*/
|
||||||
export class ProjectEngine {
|
export class ProjectEngine {
|
||||||
private runtime: InProcessRuntime;
|
private runtime: InProcessRuntime;
|
||||||
|
private started = false;
|
||||||
private prMonitor?: PrMonitor;
|
private prMonitor?: PrMonitor;
|
||||||
private prCommentHandler?: PrCommentHandler;
|
private prCommentHandler?: PrCommentHandler;
|
||||||
private notifier?: NtfyNotifier;
|
private notifier?: NtfyNotifier;
|
||||||
@@ -243,6 +244,10 @@ export class ProjectEngine {
|
|||||||
* Start the engine: initialize the runtime and all auxiliary subsystems.
|
* Start the engine: initialize the runtime and all auxiliary subsystems.
|
||||||
*/
|
*/
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
|
if (this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Start the core runtime (TaskStore, Scheduler, Executor, Triage, etc.)
|
// 1. Start the core runtime (TaskStore, Scheduler, Executor, Triage, etc.)
|
||||||
await this.runtime.start();
|
await this.runtime.start();
|
||||||
|
|
||||||
@@ -422,6 +427,7 @@ export class ProjectEngine {
|
|||||||
// 8. Start periodic merge retry sweep
|
// 8. Start periodic merge retry sweep
|
||||||
this.scheduleMergeRetry(store);
|
this.scheduleMergeRetry(store);
|
||||||
|
|
||||||
|
this.started = true;
|
||||||
runtimeLog.log(`ProjectEngine started for ${this.config.projectId}`);
|
runtimeLog.log(`ProjectEngine started for ${this.config.projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,6 +439,10 @@ export class ProjectEngine {
|
|||||||
* promptly without continuing git/verification work after shutdown starts.
|
* promptly without continuing git/verification work after shutdown starts.
|
||||||
*/
|
*/
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
|
if (!this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.shuttingDown = true;
|
this.shuttingDown = true;
|
||||||
|
|
||||||
// Stop merge retry timer
|
// Stop merge retry timer
|
||||||
@@ -518,6 +528,8 @@ export class ProjectEngine {
|
|||||||
// Stop the core runtime (Triage, Scheduler, Executor, etc.)
|
// Stop the core runtime (Triage, Scheduler, Executor, etc.)
|
||||||
await this.runtime.stop();
|
await this.runtime.stop();
|
||||||
|
|
||||||
|
this.started = false;
|
||||||
|
this.shuttingDown = false;
|
||||||
runtimeLog.log(`ProjectEngine stopped for ${this.config.projectId}`);
|
runtimeLog.log(`ProjectEngine stopped for ${this.config.projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user