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
|
||||
- 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.
|
||||
|
||||
### 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)
|
||||
- `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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -1957,6 +1957,7 @@ function MemoryTab({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [showFilePreview, setShowFilePreview] = useState(false);
|
||||
|
||||
const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]);
|
||||
const [memoryFilesLoading, setMemoryFilesLoading] = useState(false);
|
||||
@@ -2027,6 +2028,7 @@ function MemoryTab({
|
||||
setMemory(agent.memory ?? "");
|
||||
setJustSaved(false);
|
||||
setShowPreview(false);
|
||||
setShowFilePreview(false);
|
||||
setFileSwitchHint("");
|
||||
setSelectedFileJustSaved(false);
|
||||
void loadMemoryFiles();
|
||||
@@ -2220,19 +2222,58 @@ function MemoryTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
className="input config-textarea-mono config-textarea-top-spacing"
|
||||
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("");
|
||||
}}
|
||||
/>
|
||||
<div className="agent-content-toolbar config-textarea-top-spacing">
|
||||
<div className="agent-content-mode-toggle">
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
className={`btn btn-sm ${!showFilePreview ? "btn-primary" : ""}`}
|
||||
onClick={() => setShowFilePreview(false)}
|
||||
disabled={!showFilePreview}
|
||||
aria-label="Memory file edit mode"
|
||||
>
|
||||
<FileEdit size={14} />
|
||||
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 && (
|
||||
<span className="config-hint config-hint--inline-loader">
|
||||
@@ -2269,23 +2310,25 @@ function MemoryTab({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!selectedFileDirty || savingSelectedFile || !selectedFilePath || isReadOnly}
|
||||
onClick={() => void handleSaveSelectedMemoryFile()}
|
||||
>
|
||||
{savingSelectedFile ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Saving file…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle size={16} />
|
||||
Save Memory File
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!showFilePreview && (
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!selectedFileDirty || savingSelectedFile || !selectedFilePath || isReadOnly}
|
||||
onClick={() => void handleSaveSelectedMemoryFile()}
|
||||
>
|
||||
{savingSelectedFile ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Saving file…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle size={16} />
|
||||
Save Memory File
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!hasInlineChanges && justSaved && (
|
||||
<span className="config-saved-indicator">
|
||||
<CheckCircle size={14} />
|
||||
|
||||
@@ -26,6 +26,9 @@ vi.mock("../../api", () => ({
|
||||
updateAgentInstructions: vi.fn(),
|
||||
updateAgentSoul: vi.fn(),
|
||||
updateAgentMemory: vi.fn(),
|
||||
fetchAgentMemoryFiles: vi.fn(),
|
||||
fetchAgentMemoryFile: vi.fn(),
|
||||
saveAgentMemoryFile: vi.fn(),
|
||||
fetchAgentTasks: vi.fn(),
|
||||
fetchChainOfCommand: vi.fn(),
|
||||
fetchAgentBudgetStatus: vi.fn(),
|
||||
@@ -119,7 +122,7 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
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";
|
||||
|
||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||
@@ -138,6 +141,9 @@ const mockResetAgentBudget = vi.mocked(resetAgentBudget);
|
||||
const mockUpdateAgentInstructions = vi.mocked(updateAgentInstructions);
|
||||
const mockUpdateAgentSoul = vi.mocked(updateAgentSoul);
|
||||
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 mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent);
|
||||
const mockFetchDiscoveredSkills = vi.mocked(fetchDiscoveredSkills);
|
||||
@@ -231,6 +237,22 @@ describe("AgentDetailView", () => {
|
||||
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 });
|
||||
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
|
||||
mockFetchDiscoveredSkills.mockResolvedValue(MOCK_SKILLS);
|
||||
mockFetchSkillContent.mockResolvedValue({ name: "Skill", skillMd: "# Skill", files: [] });
|
||||
@@ -4248,55 +4270,26 @@ describe("AgentDetailView", () => {
|
||||
|
||||
it("renders Memory tab with textarea by default", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToMemory(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
|
||||
expect(screen.getByText("Edit")).toBeInTheDocument();
|
||||
expect(screen.getByText("Preview")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Edit mode" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Preview mode" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles between edit and preview mode", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
memory: "# Agent Memory\n\n- Item 1\n- Item 2",
|
||||
}));
|
||||
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ memory: "# Agent Memory\n\n- Item 1\n- Item 2" }));
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToMemory(user);
|
||||
|
||||
// Default: edit mode
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click Preview
|
||||
await user.click(screen.getByText("Preview"));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Preview mode" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Agent Memory")).not.toBeInTheDocument();
|
||||
expect(document.querySelector(".markdown-body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click Edit
|
||||
await user.click(screen.getByText("Edit"));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Edit mode" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
|
||||
});
|
||||
@@ -4304,18 +4297,9 @@ describe("AgentDetailView", () => {
|
||||
|
||||
it("shows placeholder when memory is empty in preview mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToMemory(user);
|
||||
|
||||
await user.click(screen.getByText("Preview"));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Preview mode" }));
|
||||
await waitFor(() => {
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
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(() => {
|
||||
expect(screen.getByText("Save Memory")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Preview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Save Memory")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Preview mode" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit mode" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides Edit button when agent is running", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
state: "running",
|
||||
memory: "This agent has memory.",
|
||||
}));
|
||||
|
||||
it("can switch inline memory to preview mode when agent is running", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running", memory: "Agent memory content" }));
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
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(() => {
|
||||
expect(screen.getByText("Preview")).toBeInTheDocument();
|
||||
// Edit button should not be in the DOM (not just disabled, hidden)
|
||||
expect(screen.queryByRole("button", { name: /Edit/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText("Select a memory file to view and edit its content...")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Heading")).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 () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
state: "running",
|
||||
memory: "Agent memory content",
|
||||
}));
|
||||
|
||||
it("shows memory file preview placeholder when selected file is empty", async () => {
|
||||
mockFetchAgentMemoryFile.mockResolvedValue({ path: ".fusion/agent-memory/agent-001/MEMORY.md", content: "" } as any);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
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(() => {
|
||||
// Preview button is visible
|
||||
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
|
||||
expect(screen.getByText("No memory file content yet. Switch to Edit mode to add content.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("can switch to preview mode when agent is running", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
state: "running",
|
||||
memory: "Agent memory content",
|
||||
}));
|
||||
|
||||
it("hides memory file edit button and disables save button for running agents", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running" }));
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToMemory(user);
|
||||
|
||||
// Click Preview button
|
||||
await user.click(screen.getByText("Preview"));
|
||||
|
||||
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 () => {
|
||||
const addToast = vi.fn();
|
||||
mockUpdateAgentMemory.mockResolvedValue({} as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={addToast}
|
||||
/>
|
||||
);
|
||||
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={addToast} />);
|
||||
await navigateToMemory(user);
|
||||
|
||||
const textarea = await screen.findByLabelText("Agent Memory");
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, "This is the agent's new memory");
|
||||
|
||||
await user.click(screen.getByText("Save Memory"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentMemory).toHaveBeenCalledWith("agent-001", "This is the agent's new memory", undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Memory saved", "success");
|
||||
|
||||
@@ -128,4 +128,36 @@ describe("NotificationService", () => {
|
||||
expect(initSpy).not.toHaveBeenCalled();
|
||||
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);
|
||||
});
|
||||
|
||||
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" });
|
||||
await sharedService.start();
|
||||
|
||||
|
||||
@@ -288,6 +288,22 @@ describe("ProjectEngine notification ownership wiring", () => {
|
||||
expect(mocks.notificationServiceStop).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", () => {
|
||||
|
||||
@@ -139,6 +139,7 @@ export interface ProjectEngineOptions {
|
||||
*/
|
||||
export class ProjectEngine {
|
||||
private runtime: InProcessRuntime;
|
||||
private started = false;
|
||||
private prMonitor?: PrMonitor;
|
||||
private prCommentHandler?: PrCommentHandler;
|
||||
private notifier?: NtfyNotifier;
|
||||
@@ -243,6 +244,10 @@ export class ProjectEngine {
|
||||
* Start the engine: initialize the runtime and all auxiliary subsystems.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Start the core runtime (TaskStore, Scheduler, Executor, Triage, etc.)
|
||||
await this.runtime.start();
|
||||
|
||||
@@ -422,6 +427,7 @@ export class ProjectEngine {
|
||||
// 8. Start periodic merge retry sweep
|
||||
this.scheduleMergeRetry(store);
|
||||
|
||||
this.started = true;
|
||||
runtimeLog.log(`ProjectEngine started for ${this.config.projectId}`);
|
||||
}
|
||||
|
||||
@@ -433,6 +439,10 @@ export class ProjectEngine {
|
||||
* promptly without continuing git/verification work after shutdown starts.
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shuttingDown = true;
|
||||
|
||||
// Stop merge retry timer
|
||||
@@ -518,6 +528,8 @@ export class ProjectEngine {
|
||||
// Stop the core runtime (Triage, Scheduler, Executor, etc.)
|
||||
await this.runtime.stop();
|
||||
|
||||
this.started = false;
|
||||
this.shuttingDown = false;
|
||||
runtimeLog.log(`ProjectEngine stopped for ${this.config.projectId}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user