diff --git a/workspace-server/internal/handlers/mcp_tools_memory_v2.go b/workspace-server/internal/handlers/mcp_tools_memory_v2.go index 00c991523..f076d5171 100644 --- a/workspace-server/internal/handlers/mcp_tools_memory_v2.go +++ b/workspace-server/internal/handlers/mcp_tools_memory_v2.go @@ -31,6 +31,7 @@ import ( "strings" "time" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/events" "github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/client" "github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract" "github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/namespace" @@ -154,6 +155,14 @@ func (h *MCPHandler) toolCommitMemoryV2(ctx context.Context, workspaceID string, } } + // Broadcast ACTIVITY_LOGGED so the canvas Memory tab live-refreshes + // (issue #1754). Fire-and-forget — broadcast failure must not roll + // back a successful plugin write. Source=agent because this is the + // MCP path (vs canvas user-driven POST /memories which the legacy + // handler logs with `memory_write_global`). + summary := "commit_memory to " + ns + logMemoryMCPActivity(ctx, h.broadcaster, workspaceID, "memory_write", resp.ID, ns, &summary) + out, _ := json.Marshal(resp) return string(out), nil } @@ -258,6 +267,11 @@ func (h *MCPHandler) toolCommitSummary(ctx context.Context, workspaceID string, if err != nil { return "", fmt.Errorf("plugin commit: %w", err) } + + // Broadcast ACTIVITY_LOGGED for live canvas refresh (#1754). + summary := "commit_summary to " + ns + logMemoryMCPActivity(ctx, h.broadcaster, workspaceID, "memory_summary_write", resp.ID, ns, &summary) + out, _ := json.Marshal(resp) return string(out), nil } @@ -320,6 +334,13 @@ func (h *MCPHandler) toolForgetMemory(ctx context.Context, workspaceID string, a }); err != nil { return "", fmt.Errorf("plugin forget: %w", err) } + + // Broadcast ACTIVITY_LOGGED for live canvas refresh (#1754). + // On forget, target_id carries the forgotten memory's id so the + // canvas can remove the row optimistically. + summary := "forget_memory " + memID + " from " + ns + logMemoryMCPActivity(ctx, h.broadcaster, workspaceID, "memory_delete", memID, ns, &summary) + return `{"forgotten":true}`, nil } @@ -393,3 +414,43 @@ func pickStringSlice(args map[string]interface{}, key string) []string { } return nil } + +// logMemoryMCPActivity fires the ACTIVITY_LOGGED broadcast for MCP-tool +// memory writes/deletes (issue #1754). The canvas Memory tab subscribes +// to memory_* activity_types via the WS feed; without this call, the +// panel only refreshes when the user hits the manual reload button. +// +// Fire-and-forget: broadcast failure must never roll back a successful +// plugin write. The underlying LogActivity helper already logs insert +// errors and swallows them — wrap with a nil-broadcaster guard so test +// fixtures without an events.Broadcaster don't nil-deref. +// +// IMPORTANT: takes *events.Broadcaster (concrete pointer), not +// events.EventEmitter (interface). Passing a nil *Broadcaster as +// EventEmitter creates a typed-nil interface that does NOT compare +// equal to nil — and would derefence inside LogActivity. Keeping the +// concrete type here lets the nil-check work reliably. +// +// Args: +// - activityType: "memory_write" / "memory_summary_write" / "memory_delete" +// - targetID: the affected memory's id (the canvas uses this for +// optimistic add/remove without a full refetch) +// - namespace: the v2 namespace the row landed in (canvas filters +// its display by readable-namespace intersection) +func logMemoryMCPActivity(ctx context.Context, broadcaster *events.Broadcaster, workspaceID, activityType, targetID, namespace string, summary *string) { + if broadcaster == nil { + return + } + source := workspaceID + target := targetID + method := namespace + LogActivity(ctx, broadcaster, ActivityParams{ + WorkspaceID: workspaceID, + ActivityType: activityType, + SourceID: &source, + TargetID: &target, + Method: &method, + Summary: summary, + Status: "ok", + }) +}